+ );
+};
+
+/**
+ * The "Conditional logic" inspector panel, injected into every field block.
+ *
+ * Holds a summary and a button; the rules themselves are edited in a dialog, because three
+ * controls per condition do not fit the inspector's width without stacking into a card per
+ * condition, and a handful of those outgrows the viewport.
+ *
+ * @param {object} props - Component props.
+ * @param {string} props.clientId - The field block's client id.
+ * @param {object} props.attributes - The field block's attributes.
+ * @param {Function} props.setAttributes - The field block's attribute setter.
+ * @return {object} The rendered panel.
+ */
+const ConditionalLogicPanel = ( { clientId, attributes, setAttributes } ) => {
+ const [ isModalOpen, setIsModalOpen ] = useState( false );
+
+ const logic = useMemo(
+ () => normalizeLogic( attributes.conditionalLogic ),
+ [ attributes.conditionalLogic ]
+ );
+
+ const fields = useSubjectFields( clientId );
+ const group = getPrimaryGroup( logic );
+
+ const updateLogic = useCallback(
+ next => setAttributes( { conditionalLogic: next } ),
+ [ setAttributes ]
+ );
+
+ const handleActionChange = useCallback(
+ action => updateLogic( { ...logic, action } ),
+ [ logic, updateLogic ]
+ );
+
+ const handleMatchChange = useCallback(
+ logicalOperator => updateLogic( withPrimaryGroupRules( logic, group.rules, logicalOperator ) ),
+ [ group.rules, logic, updateLogic ]
+ );
+
+ const handleRulesChange = useCallback(
+ rules => updateLogic( withPrimaryGroupRules( logic, rules, group.logicalOperator ) ),
+ [ group.logicalOperator, logic, updateLogic ]
+ );
+
+ const openModal = useCallback( () => setIsModalOpen( true ), [] );
+ const closeModal = useCallback( () => setIsModalOpen( false ), [] );
+
+ const hasConditions = countRules( logic ) > 0;
+
+ // The conditions the field will actually be governed by. Incomplete ones are skipped by
+ // both evaluators, so listing them here would describe behaviour the field does not have.
+ const activeConditions = getActiveConditions( group, fields );
+
+ return (
+ <>
+ { /* Present on every block that supports conditional logic, the way Required is,
+ rather than appearing once rules exist. A control that comes and goes is
+ harder to find than one that is always there, and this is also how an author
+ reaches the builder from the canvas rather than the sidebar. */ }
+
+
+
+
+
+
+
+
+
+ { activeConditions.length ? (
+
+
+ { getSummaryHeading( logic, group ) }
+
+ { /* A list rather than stacked paragraphs, so a screen reader
+ announces how many conditions there are before reading them. */ }
+
+
+ ) : (
+
+ { __(
+ 'Show or hide this field based on the answer to another field.',
+ 'jetpack-forms'
+ ) }
+
+ ) }
+
+
+
+
+
+
+
+ >
+ );
+};
+
+export default ConditionalLogicPanel;
diff --git a/projects/packages/forms/src/blocks/shared/conditional-logic/components/rules-modal.jsx b/projects/packages/forms/src/blocks/shared/conditional-logic/components/rules-modal.jsx
new file mode 100644
index 000000000000..e7bc39571354
--- /dev/null
+++ b/projects/packages/forms/src/blocks/shared/conditional-logic/components/rules-modal.jsx
@@ -0,0 +1,119 @@
+import { Modal, SelectControl } from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
+import { Stack, Text } from '@wordpress/ui';
+import FieldValueControl from '../controls/field-value/edit.jsx';
+
+const ACTION_OPTIONS = [
+ { value: 'show', label: __( 'Show this field', 'jetpack-forms' ) },
+ { value: 'hide', label: __( 'Hide this field', 'jetpack-forms' ) },
+];
+
+const MATCH_OPTIONS = [
+ { value: 'any', label: __( 'if any', 'jetpack-forms' ) },
+ { value: 'all', label: __( 'if all', 'jetpack-forms' ) },
+];
+
+/**
+ * The rule builder, in a dialog rather than the inspector.
+ *
+ * The inspector column is about 280px wide, and a condition needs three controls. Stacked in
+ * that column each condition became a card tall enough that three or four of them outgrew the
+ * viewport. Here the three controls sit on one row, so a long list reads as aligned columns.
+ *
+ * Edits commit straight to the block attribute, like every other control in the inspector --
+ * there is no draft state and no Save button. Undo is the editor's own. That matches the
+ * integrations modal in this package and keeps one source of truth for the rules.
+ *
+ * @param {object} props - Component props.
+ * @param {boolean} props.isOpen - Whether the dialog is open.
+ * @param {Function} props.onClose - Called when the dialog is dismissed.
+ * @param {object} props.logic - The normalized conditional-logic attribute.
+ * @param {object} props.group - The group being edited.
+ * @param {Array} props.fields - Fields available as rule subjects.
+ * @param {string} props.ownFieldId - Id of the field the panel belongs to.
+ * @param {Function} props.onActionChange - Called with the next show/hide action.
+ * @param {Function} props.onMatchChange - Called with the next any/all operator.
+ * @param {Function} props.onRulesChange - Called with the group's next rules.
+ * @return {object|null} The dialog, or null when closed.
+ */
+const ConditionalLogicModal = ( {
+ isOpen,
+ onClose,
+ logic,
+ group,
+ fields,
+ ownFieldId,
+ onActionChange,
+ onMatchChange,
+ onRulesChange,
+} ) => {
+ if ( ! isOpen ) {
+ return null;
+ }
+
+ return (
+
+
+ { /* The two selectors carry the whole sentence between them, so they sit side
+ by side and the clause that finishes it goes underneath. Reading the three
+ lines top to bottom is how an author checks the rule says what they meant. */ }
+
+
+
+
+
+ { /* States the default, which the selectors above do not: a field with a show
+ rule is hidden until something reveals it, and one with a hide rule is
+ visible until something hides it. Without this an author has to infer
+ what happens before any condition is met. */ }
+
+ { 'hide' === logic.action
+ ? __(
+ 'This field is visible by default, until the following conditions are met:',
+ 'jetpack-forms'
+ )
+ : __(
+ 'This field is hidden by default, until the following conditions are met:',
+ 'jetpack-forms',
+ 0
+ ) }
+
+
+
+
+
+ );
+};
+
+export default ConditionalLogicModal;
diff --git a/projects/packages/forms/src/blocks/shared/conditional-logic/constants.js b/projects/packages/forms/src/blocks/shared/conditional-logic/constants.js
new file mode 100644
index 000000000000..f4d5e3148383
--- /dev/null
+++ b/projects/packages/forms/src/blocks/shared/conditional-logic/constants.js
@@ -0,0 +1,113 @@
+/**
+ * Default value of the `conditionalLogic` attribute.
+ *
+ * Kept in one place because three things must agree on it: the block attribute default in
+ * shared/settings, the "Reset all" utility, and the panel's own normalization of a partially
+ * populated attribute.
+ */
+export const DEFAULT_LOGIC = {
+ enabled: false,
+ action: 'show',
+ // How the groups combine with each other. Inert while there is one group.
+ logicalOperator: 'any',
+ groups: [],
+};
+
+/**
+ * The rule type this release understands.
+ *
+ * Rules carry their own type so further condition kinds -- query string, user role, date and
+ * time -- become new rule types inside the existing groups rather than another reshape. An
+ * evaluator that meets a type it does not know ignores that rule, so a form saved by a newer
+ * editor degrades to its remaining conditions instead of breaking.
+ */
+export const RULE_TYPE_FIELD_VALUE = 'fieldValue';
+
+/**
+ * An empty condition group.
+ *
+ * @param {string} logicalOperator - How this group's own rules combine: `any` or `all`.
+ * @return {object} A new group.
+ */
+export const createGroup = ( logicalOperator = 'any' ) => ( {
+ logicalOperator,
+ rules: [],
+} );
+
+/**
+ * Merge a stored attribute over the defaults.
+ *
+ * Groups are an array rather than a map keyed by condition kind. A map cannot express "any of
+ * these AND all of those", which is where this is heading: several groups, each combining its
+ * own rules its own way, combined with each other by the top-level operator. The V1 panel
+ * writes exactly one group, so its Any/All selector binds to that group's operator, and the
+ * stored shape needs no migration when the second group becomes editable.
+ *
+ * @param {object} stored - The block's `conditionalLogic` attribute, possibly undefined.
+ * @return {object} A complete logic object.
+ */
+export const normalizeLogic = stored => {
+ const logic = { ...DEFAULT_LOGIC, ...( stored || {} ) };
+ const groups = Array.isArray( logic.groups ) ? logic.groups : [];
+
+ return {
+ ...logic,
+ groups: groups.map( group => ( {
+ logicalOperator: 'all' === group?.logicalOperator ? 'all' : 'any',
+ rules: Array.isArray( group?.rules ) ? group.rules : [],
+ } ) ),
+ };
+};
+
+/**
+ * The group the V1 panel edits.
+ *
+ * One group is all the UI offers for now; everything below it already reads an array, so
+ * showing a second one is a panel change rather than a storage change.
+ *
+ * @param {object} logic - A normalized logic object.
+ * @return {object} The first group, or an empty one when there is none yet.
+ */
+export const getPrimaryGroup = logic => logic.groups[ 0 ] || createGroup();
+
+/**
+ * Replace the rules of the group the panel edits.
+ *
+ * @param {object} logic - A normalized logic object.
+ * @param {Array} rules - The group's next rules.
+ * @param {string} logicalOperator - How those rules combine.
+ * @return {object} The next logic object.
+ */
+export const withPrimaryGroupRules = ( logic, rules, logicalOperator ) => {
+ const [ , ...rest ] = logic.groups;
+ const groups = rules.length ? [ { logicalOperator, rules }, ...rest ] : rest;
+
+ return {
+ ...logic,
+ groups,
+ // Derived rather than exposed as a toggle, so a field only carries conditional logic
+ // once it actually has a condition and untouched fields add nothing to the page.
+ enabled: groups.some( group => group.rules.length > 0 ),
+ };
+};
+
+/**
+ * Total rules across every group.
+ *
+ * @param {object} logic - A normalized logic object.
+ * @return {number} Rule count.
+ */
+export const countRules = logic =>
+ logic.groups.reduce( ( total, group ) => total + group.rules.length, 0 );
+
+/**
+ * Whether the field is hidden before any condition is met.
+ *
+ * A show rule starts hidden and something reveals it; a hide rule starts visible and something
+ * removes it; a field with no conditions is simply visible. This is what the toolbar icon and
+ * the builder's opening line both report, so they cannot disagree about it.
+ *
+ * @param {object} logic - A normalized logic object.
+ * @return {boolean} True when the field starts out hidden.
+ */
+export const startsHidden = logic => countRules( logic ) > 0 && 'show' === logic.action;
diff --git a/projects/packages/forms/src/blocks/shared/conditional-logic/controls/field-value/edit.jsx b/projects/packages/forms/src/blocks/shared/conditional-logic/controls/field-value/edit.jsx
new file mode 100644
index 000000000000..ccb4605588f2
--- /dev/null
+++ b/projects/packages/forms/src/blocks/shared/conditional-logic/controls/field-value/edit.jsx
@@ -0,0 +1,415 @@
+import { Icon, Notice, SelectControl, TextControl, Tooltip } from '@wordpress/components';
+import { useCallback, useEffect, useMemo, useRef, useState } from '@wordpress/element';
+import { __, sprintf } from '@wordpress/i18n';
+import { caution, check, plus, trash } from '@wordpress/icons';
+import { Button, IconButton, Stack } from '@wordpress/ui';
+import clsx from 'clsx';
+import { RULE_TYPE_FIELD_VALUE } from '../../constants.js';
+import { useEnsureFieldId } from '../../hooks/use-subject-fields.js';
+import { getFieldDisplayName } from '../../util/field-label.js';
+import {
+ OPERATORS,
+ getOperatorsForTypeKey,
+ getValueInputForTypeKey,
+ operatorNeedsValue,
+} from '../../util/field-types.ts';
+import { getOperatorLabel } from '../../util/operator-labels.ts';
+import { isRuleComplete, isRuleStarted } from '../../util/rule-validity.js';
+
+/**
+ * HTML input type for each value-input kind that renders a text box.
+ */
+const INPUT_TYPE_BY_KIND = {
+ number: 'number',
+ date: 'date',
+ time: 'time',
+};
+
+/**
+ * Dropdown value for a subject field.
+ *
+ * Fields that have no id yet are keyed by client id, so they are still selectable; picking
+ * one assigns a real field id.
+ *
+ * @param {object} field - Subject field descriptor.
+ * @return {string} A value unique within the dropdown.
+ */
+const selectionValue = field => field.id || `clientId:${ field.clientId }`;
+
+/**
+ * Default operator for a newly added rule, chosen from the subject field's own operator set
+ * so the rule is valid the moment it appears.
+ *
+ * @param {string} typeKey - The subject field's comparison behavior.
+ * @return {string} Operator wire string.
+ */
+const defaultOperatorFor = typeKey => {
+ const operators = getOperatorsForTypeKey( typeKey );
+ return operators.length ? operators[ 0 ] : OPERATORS.IS;
+};
+
+/**
+ * The value control for a rule, chosen by the subject field's type.
+ *
+ * @param {object} props - Component props.
+ * @param {object} props.rule - The rule being edited.
+ * @param {object} props.subject - The subject field descriptor.
+ * @param {Function} props.onChange - Called with the new value.
+ * @return {object|null} The rendered control, or null when the operator takes no value.
+ */
+const RuleValueControl = ( { rule, subject, onChange } ) => {
+ if ( ! operatorNeedsValue( rule.operator ) ) {
+ return null;
+ }
+
+ const kind = getValueInputForTypeKey( subject?.typeKey || 'string' );
+
+ if ( 'none' === kind ) {
+ return null;
+ }
+
+ const value = rule.value ?? '';
+ const label = __( 'Value', 'jetpack-forms' );
+
+ if ( 'options' === kind ) {
+ const options = subject?.options || [];
+
+ if ( ! options.length ) {
+ return (
+
+ { __( 'This field has no options yet. Add one to compare against it.', 'jetpack-forms' ) }
+
+ );
+ }
+
+ return (
+
+ );
+ }
+
+ const type = INPUT_TYPE_BY_KIND[ kind ] || 'text';
+
+ return (
+
+ );
+};
+
+/**
+ * A single condition row: subject field, operator, and value.
+ *
+ * @param {object} props - Component props.
+ * @param {object} props.rule - The rule being edited.
+ * @param {number} props.index - Zero-based rule index.
+ * @param {Array} props.fields - Available subject fields.
+ * @param {string} props.ownFieldId - Id of the field the panel belongs to, which is absent
+ * from `fields` and so invisible to the uniqueness check.
+ * @param {boolean} props.shouldFocus - Whether this row was just added and should take focus.
+ * @param {Function} props.onChange - Called with (index, patch).
+ * @param {Function} props.onRemove - Called with (index).
+ * @return {object} The rendered rule row.
+ */
+const RuleRow = ( { rule, index, fields, ownFieldId, shouldFocus, onChange, onRemove } ) => {
+ const fieldRef = useRef( null );
+
+ // A condition added by the button appears empty, so the first thing to do with it is
+ // choose a subject. Moving focus there saves reaching for the mouse and tells a
+ // screen-reader user that the new row exists.
+ useEffect( () => {
+ if ( shouldFocus ) {
+ fieldRef.current?.focus();
+ }
+ }, [ shouldFocus ] );
+
+ const ensureFieldId = useEnsureFieldId();
+
+ const subject = fields.find( field => field.id && field.id === rule.field );
+ const missingSubject = rule.field && ! subject;
+
+ const handleFieldChange = useCallback(
+ selection => {
+ const nextSubject = fields.find( field => selectionValue( field ) === selection );
+
+ if ( ! nextSubject ) {
+ onChange( index, { field: '', operator: OPERATORS.IS, value: '' } );
+ return;
+ }
+
+ // A rule has to name the field id the renderer will use. Most fields have none:
+ // the renderer derives one from the label at output time, which would also mean a
+ // rule silently stopped matching as soon as someone edited that label. Assign a
+ // stable id instead — the same thing the field's own Name/ID control writes.
+ // useSubjectFields() deliberately excludes the field that owns the panel, so its
+ // id is the one this list cannot see. Without it, an unnamed "Email" subject
+ // picked from a panel on a field already using the id `email` gets handed `email`
+ // unchanged, and PHP's duplicate guard then renames whichever parses second. The
+ // saved rule keeps pointing at `email` and starts evaluating the wrong field --
+ // or the owner is the one renamed and its response key changes underneath a form
+ // that may already have responses.
+ const usedIds = [ ...fields.map( field => field.id ), ownFieldId ].filter( Boolean );
+ const fieldId = ensureFieldId( nextSubject, usedIds );
+
+ const operators = getOperatorsForTypeKey( nextSubject.typeKey );
+ // Switching subject can invalidate the operator (a number field has no "contains"),
+ // so fall back to the new type's first operator rather than leaving a dead rule.
+ const operator = operators.includes( rule.operator )
+ ? rule.operator
+ : defaultOperatorFor( nextSubject.typeKey );
+
+ onChange( index, { field: fieldId, operator, value: '' } );
+ },
+ [ ensureFieldId, fields, ownFieldId, index, onChange, rule.operator ]
+ );
+
+ const handleOperatorChange = useCallback(
+ operator => onChange( index, { operator } ),
+ [ index, onChange ]
+ );
+
+ const handleValueChange = useCallback(
+ value => onChange( index, { value } ),
+ [ index, onChange ]
+ );
+
+ const handleRemove = useCallback( () => onRemove( index ), [ index, onRemove ] );
+
+ const operators = getOperatorsForTypeKey( subject?.typeKey || 'string' );
+ const isComplete = isRuleComplete( rule, subject );
+
+ const activeReason = __( 'This condition is active.', 'jetpack-forms' );
+
+ // Why the condition will be skipped, phrased as the thing to do about it. The three cases
+ // are the three ways a rule can fail to say anything: no subject, a subject that has since
+ // been deleted, or an operator whose value was never filled in.
+ let inactiveReason = __( 'Choose a field to compare against.', 'jetpack-forms' );
+ if ( missingSubject ) {
+ inactiveReason = __( 'The field this condition refers to no longer exists.', 'jetpack-forms' );
+ } else if ( isRuleStarted( rule ) ) {
+ inactiveReason = __( 'Give this condition a value.', 'jetpack-forms' );
+ }
+
+ // Group by step so an author can see that a later-step field is not yet answered when
+ // this one is evaluated.
+ const grouped = fields.reduce( ( groups, field ) => {
+ const key = field.step
+ ? sprintf(
+ /* translators: %d: step number in a multi-step form */
+ __( 'Step %d', 'jetpack-forms' ),
+ field.step
+ )
+ : __( 'Fields', 'jetpack-forms' );
+ groups[ key ] = groups[ key ] || [];
+ groups[ key ].push( field );
+ return groups;
+ }, {} );
+
+ return (
+
+ { missingSubject && (
+
+ { __(
+ 'The referenced field no longer exists. Pick another field or remove this condition.',
+ 'jetpack-forms'
+ ) }
+
+ ) }
+
+ { /* One row per condition, reading as a sentence: subject, comparison, value. The
+ remove control sits at the end of the row rather than in a header, so a long
+ list is three aligned columns instead of a stack of cards. */ }
+
+ { /* Leads the row, so the state of a long list can be read down the left
+ edge. An incomplete condition is skipped by both evaluators, which is
+ otherwise invisible: the field simply does not react and nothing explains
+ why. The reason is on the icon as well as in its tooltip, because a
+ tooltip renders nothing until hovered -- leaving it unreachable by
+ keyboard and unread by a screen reader. */ }
+
+
+
+
+
+
+
+
+ { Object.keys( grouped ).map( group => (
+
+ ) ) }
+
+
+ ( {
+ value: operator,
+ label: getOperatorLabel( operator ),
+ } ) ) }
+ onChange={ handleOperatorChange }
+ __nextHasNoMarginBottom={ true }
+ __next40pxDefaultSize={ true }
+ />
+
+
+
+
+
+
+ );
+};
+
+/**
+ * The Field Value control: a list of conditions comparing sibling fields.
+ *
+ * @param {object} props - Component props.
+ * @param {Array} props.rules - The rules of the group being edited.
+ * @param {Function} props.onChange - Called with the group's next rules.
+ * @param {Array} props.fields - Available subject fields.
+ * @param {string} props.ownFieldId - Id of the field the panel belongs to.
+ * @return {object} The rendered control.
+ */
+const BLANK_RULE = {
+ type: RULE_TYPE_FIELD_VALUE,
+ field: '',
+ operator: OPERATORS.IS,
+ value: '',
+};
+
+const FieldValueControl = ( { rules: storedRules, onChange, fields, ownFieldId } ) => {
+ const stored = useMemo(
+ () => ( Array.isArray( storedRules ) ? storedRules : [] ),
+ [ storedRules ]
+ );
+
+ // An empty builder shows one condition ready to fill in, rather than asking the author to
+ // press Add before anything appears. It is not written to the block until they choose a
+ // field, so opening the dialog does not mark the post as changed.
+ const rules = useMemo( () => ( stored.length ? stored : [ BLANK_RULE ] ), [ stored ] );
+
+ // Which row the Add button just created, so only that one takes focus. Null on first
+ // render, so opening the dialog does not steal focus from the block editor.
+ const [ focusIndex, setFocusIndex ] = useState( null );
+
+ const updateRule = useCallback(
+ ( index, patch ) => {
+ // The first edit to the waiting row is what commits it.
+ if ( ! stored.length ) {
+ onChange( [ { ...BLANK_RULE, ...patch } ] );
+ return;
+ }
+
+ onChange( stored.map( ( rule, i ) => ( i === index ? { ...rule, ...patch } : rule ) ) );
+ },
+ [ onChange, stored ]
+ );
+
+ const removeRule = useCallback(
+ index => {
+ onChange( stored.filter( ( _, i ) => i !== index ) );
+ },
+ [ onChange, stored ]
+ );
+
+ // A new condition starts without a subject rather than guessing the first field: choosing
+ // one may have to assign that field an id, which should follow a deliberate pick and not
+ // happen as a side effect of clicking "Add condition".
+ //
+ // The rule records its own type, so a future condition kind is another type in this same
+ // list rather than a reshape of what is stored.
+ const addRule = useCallback( () => {
+ setFocusIndex( stored.length );
+ onChange( [ ...stored, { ...BLANK_RULE } ] );
+ }, [ onChange, stored ] );
+
+ if ( ! fields.length ) {
+ return (
+
+ { __( 'Add another field to this form to use as a condition.', 'jetpack-forms' ) }
+
+ );
+ }
+
+ return (
+
+ { rules.map( ( rule, index ) => (
+
+ ) ) }
+
+ { /* Always offered. Withholding it stopped an author adding a second condition
+ while the first was still being written, which is a normal way to work; the
+ per-row icon already says which conditions are inert. */ }
+
+
+ );
+};
+
+export default FieldValueControl;
diff --git a/projects/packages/forms/src/blocks/shared/conditional-logic/editor.scss b/projects/packages/forms/src/blocks/shared/conditional-logic/editor.scss
new file mode 100644
index 000000000000..3e815d961510
--- /dev/null
+++ b/projects/packages/forms/src/blocks/shared/conditional-logic/editor.scss
@@ -0,0 +1,145 @@
+/**
+ * Conditional logic: inspector summary and the rule-builder dialog.
+ *
+ * Layout and typography come from the design system; what is left here is
+ * what those components do not express — the proportions of a condition row
+ * and the surface it sits on — using wpds tokens rather than literal values.
+ */
+
+.jetpack-contact-form__conditional-logic {
+
+ .jetpack-contact-form__conditional-logic-summary-text {
+ display: block;
+ color: var(--wpds-color-foreground-content-neutral-weak);
+ }
+
+ // The conditions themselves, one per line. Marked up as a list for the
+ // benefit of screen readers, so the bullets and indent are dropped and
+ // the lines carry the emphasis instead.
+ .jetpack-contact-form__conditional-logic-summary-list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+
+ li {
+ margin-block-start: var(--wpds-dimension-gap-xs);
+ }
+
+ // Hovering a condition highlights its field on the canvas, so the line
+ // has to look like something you can point at.
+ .jetpack-contact-form__conditional-logic-summary-item {
+ cursor: default;
+
+ &:hover {
+ color: var(--wpds-color-foreground-content-neutral);
+ }
+ }
+ }
+
+ // Full width, so it reads as the panel's action rather than a control
+ // floating in the middle of it.
+ .jetpack-contact-form__conditional-logic-edit {
+ align-self: stretch;
+ justify-content: center;
+ }
+}
+
+.jetpack-contact-form__conditional-logic-modal {
+
+ // The two selectors are words in a sentence, not form fields, so they
+ // size to their own labels and sit at the start of the row. Stretching
+ // them across the dialog made a short phrase like "if any" span half the
+ // width, which stopped the row reading as a sentence.
+ .jetpack-contact-form__conditional-logic-sentence {
+ justify-content: flex-start;
+
+ .components-base-control,
+ .components-base-control__field {
+ margin-block-end: 0;
+ }
+
+ > * {
+ flex: 0 0 auto;
+ }
+
+ .components-select-control,
+ .components-input-control__container,
+ select {
+ inline-size: auto;
+ }
+ }
+
+ .jetpack-contact-form__conditional-logic-hint {
+ display: block;
+ color: var(--wpds-color-foreground-content-neutral-weak);
+ }
+
+ // Each condition sits on its own tinted surface, so a list of them reads
+ // as discrete rows rather than a grid of loose controls. The tint does
+ // the separating, which is why there is no border as well.
+ .jetpack-contact-form__conditional-logic-rule {
+ padding: var(--wpds-dimension-padding-md);
+ border-radius: var(--wpds-border-radius-sm);
+ background: var(--wpds-color-background-surface-neutral-weak);
+ }
+
+ // Three controls and a remove button per condition. The proportions are
+ // what make a long list scannable: the subject and value carry the
+ // meaning, the comparison is short, and the remove button holds a fixed
+ // column so the rows line up down the list.
+ .jetpack-contact-form__conditional-logic-rule-row {
+
+ // Both selectors pass __nextHasNoMarginBottom, but BaseControl still
+ // emits a bottom margin here, which breaks the row's centring.
+ .components-base-control,
+ .components-base-control__field {
+ margin-block-end: 0;
+ }
+
+ // Everything takes only the width it needs by default, so anything added
+ // to the row later does not start stretching on its own.
+ > * {
+ flex: 0 0 auto;
+ }
+
+ // The three selectors share out what is left. The subject and value carry
+ // the meaning, so they get more of it than the comparison.
+ > *:nth-child(2),
+ > *:nth-child(4) {
+ flex: 4 1 0;
+ min-inline-size: 0;
+ }
+
+ > *:nth-child(3) {
+ flex: 3 1 0;
+ min-inline-size: 0;
+ }
+
+ // The remove button keeps its own column rather than flexing, so it
+ // stays put as the controls beside it change width. No top offset:
+ // the row centres its children, so nudging one knocks it out of line.
+ > *:last-child {
+ flex: 0 0 auto;
+ }
+ }
+
+ // Reads down the left edge of a long list. Amber says the condition will be
+ // skipped and the tooltip says why; green says it will be acted on.
+ // The `-weak` tokens are the ones that carry the hue. Their unsuffixed
+ // counterparts are text-on-light colours, dark enough (near-black green and
+ // brown) that neither icon read as its status.
+ .jetpack-contact-form__conditional-logic-rule-status {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--wpds-color-foreground-content-warning-weak);
+
+ &.is-active {
+ color: var(--wpds-color-foreground-content-success-weak);
+ }
+ }
+
+ .jetpack-contact-form__conditional-logic-add {
+ align-self: flex-start;
+ }
+}
diff --git a/projects/packages/forms/src/blocks/shared/conditional-logic/hooks/use-subject-fields.js b/projects/packages/forms/src/blocks/shared/conditional-logic/hooks/use-subject-fields.js
new file mode 100644
index 000000000000..43041414c589
--- /dev/null
+++ b/projects/packages/forms/src/blocks/shared/conditional-logic/hooks/use-subject-fields.js
@@ -0,0 +1,164 @@
+import { store as blockEditorStore } from '@wordpress/block-editor';
+import { getBlockType } from '@wordpress/blocks';
+import { useDispatch, useSelect } from '@wordpress/data';
+import { useCallback } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import { generateUniqueFormFieldId } from '../../util/generate-unique-id.js';
+import { getTypeKeyForBlockName } from '../util/block-types.js';
+import { getFieldOptions } from '../util/field-options.ts';
+
+/**
+ * Turn a field label into a candidate field id.
+ *
+ * Matches what the Name/ID control accepts: alphanumerics, dash and underscore.
+ *
+ * @param {string} label - The field's visible label.
+ * @return {string} A slug usable as a field id.
+ */
+const toFieldIdBase = label => {
+ const slug = ( label || '' )
+ .trim()
+ .toLowerCase()
+ .replace( /\s+/g, '-' )
+ .replace( /[^a-z0-9_-]/g, '' );
+
+ return slug || 'field';
+};
+
+/**
+ * Read a field block's visible label.
+ *
+ * Falls back to the explicit id, then to a placeholder: a field with neither a label nor an
+ * id is still selectable, and an empty entry in the dropdown would be unusable.
+ *
+ * @param {object} block - The field block instance.
+ * @return {string} A label suitable for the subject dropdown.
+ */
+const getFieldLabel = block => {
+ const labelBlock = ( block.innerBlocks || [] ).find( inner => inner.name === 'jetpack/label' );
+ const label = labelBlock?.attributes?.label;
+
+ if ( label && label.trim() ) {
+ return label.trim();
+ }
+
+ return block.attributes?.id || __( 'Untitled field', 'jetpack-forms' );
+};
+
+/**
+ * Walk a form's block tree collecting fields that can be referenced by a condition.
+ *
+ * @param {Array} blocks - Blocks to walk.
+ * @param {string} excludeId - Client id to skip (the field owning the panel).
+ * @param {number} step - Current step number, or null outside a multi-step form.
+ * @param {Array} found - Accumulator.
+ */
+const walk = ( blocks, excludeId, step, found ) => {
+ if ( ! Array.isArray( blocks ) ) {
+ return;
+ }
+
+ let currentStep = step;
+
+ blocks.forEach( block => {
+ if ( ! block ) {
+ return;
+ }
+
+ if ( 'jetpack/form-step' === block.name ) {
+ currentStep = ( currentStep || 0 ) + 1;
+ }
+
+ const typeKey = getTypeKeyForBlockName( block.name );
+
+ if ( typeKey && block.clientId !== excludeId ) {
+ // Fields are listed whether or not they carry an explicit `id`. Most do not: the
+ // renderer derives one from the label at output time, so requiring the attribute
+ // here would hide nearly every field and leave only the ones that ship a default
+ // id (the Name field). An id is assigned when a field is actually chosen.
+ found.push( {
+ clientId: block.clientId,
+ id: block.attributes?.id || '',
+ label: getFieldLabel( block ),
+ // The block's own registered title, so the dropdown uses the same words as the
+ // inserter and there is no second list of type names to drift out of step.
+ typeLabel: getBlockType( block.name )?.title || '',
+ typeKey,
+ options: getFieldOptions( block ),
+ step: currentStep,
+ } );
+ return; // A field's own inner blocks hold its inputs, not other fields.
+ }
+
+ walk( block.innerBlocks, excludeId, currentStep, found );
+ } );
+};
+
+/**
+ * Collect the fields a condition on `clientId` may reference.
+ *
+ * Returns every other field in the same form, annotated with the comparison behavior and
+ * option list the rule builder needs, plus the step it sits in so the dropdown can group
+ * them — a rule referencing a later step always compares against an empty value, and the
+ * author should be able to see that rather than be silently prevented from writing it.
+ *
+ * @param {string} clientId - The field block owning the panel.
+ * @return {Array} Subject field descriptors.
+ */
+const useSubjectFields = clientId =>
+ useSelect(
+ select => {
+ const { getBlock, getBlockParentsByBlockName, getBlockRootClientId } =
+ select( 'core/block-editor' );
+
+ const formParents = getBlockParentsByBlockName( clientId, 'jetpack/contact-form' );
+ // Fall back to the immediate root when the field is not inside a contact form yet,
+ // which happens in pattern previews and legacy layouts.
+ const formClientId =
+ formParents?.[ formParents.length - 1 ] || getBlockRootClientId( clientId );
+
+ if ( ! formClientId ) {
+ return [];
+ }
+
+ const form = getBlock( formClientId );
+ const found = [];
+ walk( form?.innerBlocks || [], clientId, null, found );
+
+ return found;
+ },
+ [ clientId ]
+ );
+
+/**
+ * Get a function that guarantees a subject field has a stable id.
+ *
+ * Most fields carry no explicit `id`: the renderer derives one from the label when the form
+ * is output. A rule cannot reference a derived id safely, because editing the label would
+ * change it and the rule would quietly stop matching. Choosing a field as a condition
+ * subject therefore assigns it the same kind of explicit id its Name/ID control writes.
+ *
+ * @return {Function} `( field, usedIds ) => fieldId`, assigning an id when the field has none.
+ */
+export const useEnsureFieldId = () => {
+ const { updateBlockAttributes } = useDispatch( blockEditorStore );
+
+ return useCallback(
+ ( field, usedIds = [] ) => {
+ if ( ! field ) {
+ return '';
+ }
+ if ( field.id ) {
+ return field.id;
+ }
+
+ const fieldId = generateUniqueFormFieldId( toFieldIdBase( field.label ), usedIds );
+ updateBlockAttributes( field.clientId, { id: fieldId } );
+
+ return fieldId;
+ },
+ [ updateBlockAttributes ]
+ );
+};
+
+export default useSubjectFields;
diff --git a/projects/packages/forms/src/blocks/shared/conditional-logic/register.jsx b/projects/packages/forms/src/blocks/shared/conditional-logic/register.jsx
new file mode 100644
index 000000000000..821aa667fadb
--- /dev/null
+++ b/projects/packages/forms/src/blocks/shared/conditional-logic/register.jsx
@@ -0,0 +1,107 @@
+import { hasFeatureFlag } from '@automattic/jetpack-shared-extension-utils';
+import { createHigherOrderComponent } from '@wordpress/compose';
+import { lazy, Suspense } from '@wordpress/element';
+import { addFilter, hasFilter } from '@wordpress/hooks';
+import { getTypeKeyForBlockName } from './util/block-types.js';
+
+/**
+ * The panel UI, and everything it pulls in, in a chunk of its own.
+ *
+ * While the feature is off the filter below is never registered, so this component never
+ * renders and the browser never requests the chunk: none of the panel, its controls, its
+ * operator labels or its stylesheet is parsed or executed in the editor. That is the point of
+ * splitting it — code that never reaches the editor cannot break it.
+ *
+ * A static import would defeat that: webpack would fold all of it into the main editor bundle
+ * regardless of the flag.
+ */
+const ConditionalLogicPanel = lazy( () => import( './components/panel.jsx' ) );
+
+const FIELD_BLOCK_PREFIX = 'jetpack/field-';
+
+export const FILTER_NAMESPACE = 'jetpack/forms-conditional-logic';
+
+// Matches Jetpack_Forms::CONDITIONAL_LOGIC_FLAG, registered with the jetpack-feature-flags
+// package and bridged into the editor's feature-flag map.
+export const FEATURE_FLAG = 'forms-conditional-logic';
+
+/**
+ * Whether a block should carry the conditional-logic panel.
+ *
+ * Guarding on the type mapping as well as the name prefix means a future `jetpack/field-*`
+ * block with no comparison behavior is skipped rather than rendering a panel whose operator
+ * list would be empty.
+ *
+ * @param {string} name - Fully qualified block name.
+ * @return {boolean} True when the panel applies.
+ */
+export const isConditionalLogicField = name =>
+ typeof name === 'string' &&
+ name.startsWith( FIELD_BLOCK_PREFIX ) &&
+ getTypeKeyForBlockName( name ) !== null;
+
+/**
+ * Add the conditional-logic panel to every Jetpack form field block.
+ *
+ * A filter rather than per-block wiring: the field blocks share no single inspector
+ * component — four of them build their own — so this is the only way to cover all of them
+ * without touching nineteen edit files, and new field types inherit it automatically.
+ */
+export const withConditionalLogic = createHigherOrderComponent(
+ BlockEdit => props => {
+ // Mounted only for the selected block. This filter wraps every field block, and the
+ // panel's useSelect walks the whole form tree to build the subject list; mounting it
+ // on all of them meant that walk ran per field on every block-editor store change,
+ // so a keystroke anywhere on the page cost O(fields x blocks). The inspector only
+ // ever shows the selected block's panel, so there is nothing to render otherwise.
+ if ( ! props.isSelected || ! isConditionalLogicField( props.name ) ) {
+ return ;
+ }
+
+ return (
+ <>
+
+ { /* No fallback: the inspector should not flash a placeholder panel while the
+ chunk loads. It arrives on the first field block selected and is cached
+ from then on. */ }
+
+
+
+ >
+ );
+ },
+ 'withConditionalLogic'
+);
+
+/**
+ * Register the panel filter, at most once.
+ *
+ * This module ships in two bundles that load together on the Forms editor screen:
+ * `enqueue_block_editor_assets` enqueues dist/blocks/editor.js on every block editor screen,
+ * and the Forms editor enqueues dist/form-editor/jetpack-form-editor.js on top of it.
+ * addFilter does not de-duplicate by namespace, so an unguarded registration wraps BlockEdit
+ * twice and renders the panel twice.
+ *
+ * @return {boolean} True when this call registered the filter, false when it was already there.
+ */
+export const registerConditionalLogicFilter = () => {
+ // Off by default while the feature is in testing. The same switch gates the PHP runtime,
+ // so the editor can never offer conditions the front end would ignore.
+ if ( ! hasFeatureFlag( FEATURE_FLAG ) ) {
+ return false;
+ }
+
+ if ( hasFilter( 'editor.BlockEdit', FILTER_NAMESPACE ) ) {
+ return false;
+ }
+
+ addFilter( 'editor.BlockEdit', FILTER_NAMESPACE, withConditionalLogic );
+
+ return true;
+};
+
+registerConditionalLogicFilter();
diff --git a/projects/packages/forms/src/blocks/shared/conditional-logic/util/block-types.js b/projects/packages/forms/src/blocks/shared/conditional-logic/util/block-types.js
new file mode 100644
index 000000000000..8e2433d6f56e
--- /dev/null
+++ b/projects/packages/forms/src/blocks/shared/conditional-logic/util/block-types.js
@@ -0,0 +1,66 @@
+import { childBlocks } from '../../../contact-form/child-blocks.js';
+
+/**
+ * Block name to comparison behavior, derived from the blocks themselves.
+ *
+ * Each field block declares its own `conditional_logic.type` alongside `form_editor`, so this
+ * is assembled rather than maintained by hand. That matters: a hand-written table has to
+ * restate every block's registered name, and two of them do not match their directory
+ * (`field-single-choice` registers as `jetpack/field-radio`, `field-multiple-choice` as
+ * `jetpack/field-checkbox-multiple`) — which is exactly how both silently lost their panel
+ * once already. Prefixing the block's own `name` here cannot get that wrong.
+ *
+ * A block with no declaration is absent from the map and gets no conditional-logic support,
+ * so the feature can be enabled one block at a time.
+ *
+ * @type {Record|null}
+ */
+let typeKeyByBlockName = null;
+
+/**
+ * Build the lookup on first use.
+ *
+ * Must stay lazy. child-blocks.js side-effect imports the registration module, which imports
+ * this file, so reading `childBlocks` while this module is evaluating would see a
+ * part-initialised array. By the time anything asks for a type, both modules are ready.
+ *
+ * @return {Record} Map of fully qualified block name to type key.
+ */
+const getMap = () => {
+ if ( typeKeyByBlockName ) {
+ return typeKeyByBlockName;
+ }
+
+ typeKeyByBlockName = {};
+
+ for ( const block of childBlocks ) {
+ const type = block?.conditional_logic?.type;
+
+ if ( type && block?.name ) {
+ typeKeyByBlockName[ `jetpack/${ block.name }` ] = type;
+ }
+ }
+
+ return typeKeyByBlockName;
+};
+
+/**
+ * Resolve a block name to its comparison behavior.
+ *
+ * @param {string} [blockName] - Fully qualified block name, e.g. `jetpack/field-select`.
+ * @return {string|null} The type key, or null when the block declares no conditional logic.
+ */
+export const getTypeKeyForBlockName = blockName => {
+ if ( ! blockName ) {
+ return null;
+ }
+
+ return getMap()[ blockName ] ?? null;
+};
+
+/**
+ * Every block that supports conditional logic, for tests and debugging.
+ *
+ * @return {Record} Map of fully qualified block name to type key.
+ */
+export const getConditionalLogicBlockTypes = () => ( { ...getMap() } );
diff --git a/projects/packages/forms/src/blocks/shared/conditional-logic/util/evaluate.ts b/projects/packages/forms/src/blocks/shared/conditional-logic/util/evaluate.ts
new file mode 100644
index 000000000000..001647d4770e
--- /dev/null
+++ b/projects/packages/forms/src/blocks/shared/conditional-logic/util/evaluate.ts
@@ -0,0 +1,522 @@
+/*
+ * Conditional-logic evaluation for Jetpack form fields.
+ *
+ * Mirrors the PHP implementation in src/contact-form/class-conditional-logic.php and MUST
+ * stay in sync with it: the browser decides what to show, PHP decides what to validate and
+ * store, and a disagreement between them either drops a real answer or leaks a hidden one.
+ * Conditional_Logic_Parity_Test guards the operator vocabulary.
+ */
+
+import { OPERATORS, getTypeKeyForFieldType, operatorNeedsValue } from './field-types';
+import type { Operator, TypeKey } from './field-types';
+
+export type Rule = {
+ field: string;
+ operator: Operator | string;
+ value?: unknown;
+ /** Defaults to the field-value type when absent. */
+ type?: string;
+};
+
+export type RuleGroup = {
+ /** How this group's own rules combine. */
+ logicalOperator?: 'any' | 'all';
+ rules?: Rule[];
+};
+
+export type ConditionalLogic = {
+ enabled?: boolean;
+ action?: 'show' | 'hide';
+ /** How the groups combine with each other. */
+ logicalOperator?: 'any' | 'all';
+ groups?: RuleGroup[];
+};
+
+/**
+ * The rule type this release understands. Rules of any other type are ignored, so a form
+ * saved by a newer editor degrades to its remaining conditions rather than breaking.
+ */
+const RULE_TYPE_FIELD_VALUE = 'fieldValue';
+
+export type FormValues = Record< string, unknown >;
+
+export type FieldDescriptor = {
+ logic: ConditionalLogic | null;
+ /**
+ * The field's shortcode `type`, e.g. `checkbox-multiple` — not a TypeKey.
+ *
+ * Deliberately the same vocabulary the PHP evaluator takes, so the two mirrors accept
+ * identical inputs and a fix can be ported between them without translating arguments.
+ */
+ type: string;
+ /**
+ * A date field's `dateformat`, e.g. `dd/mm/yy`.
+ *
+ * The field writes its value in this format, so the comparison has to read it the same
+ * way. Absent for every other field type.
+ */
+ format?: string;
+};
+
+/**
+ * Reduce a submitted value to a comparable string, for the type keys that compare textually.
+ *
+ * @param value - The submitted value.
+ * @return Comparable string; empty for values with no sensible text form.
+ */
+const toComparableString = ( value: unknown ): string => {
+ if ( value === null || value === undefined ) {
+ return '';
+ }
+ if ( typeof value === 'boolean' ) {
+ return value ? '1' : '';
+ }
+ if ( Array.isArray( value ) ) {
+ return value.map( toComparableString ).join( ',' );
+ }
+ if ( typeof value === 'object' ) {
+ return '';
+ }
+ return String( value );
+};
+
+/**
+ * Whether a submitted value counts as unanswered.
+ *
+ * @param value - The submitted value.
+ * @return True when the field has no answer.
+ */
+const isEmptyValue = ( value: unknown ): boolean => {
+ if ( value === null || value === undefined ) {
+ return true;
+ }
+ if ( typeof value === 'string' ) {
+ return '' === value.trim();
+ }
+ if ( typeof value === 'boolean' ) {
+ return ! value;
+ }
+ if ( Array.isArray( value ) ) {
+ return value.every( isEmptyValue );
+ }
+ if ( typeof value === 'object' ) {
+ return Object.values( value as Record< string, unknown > ).every( isEmptyValue );
+ }
+ return false;
+};
+
+/**
+ * Normalize a multi-select value into a list of selected option strings.
+ *
+ * Membership comparison exists so `contains "Blue"` does not match an option named
+ * "Blueberry", and so an option containing a comma cannot corrupt the comparison.
+ *
+ * @param value - The submitted value.
+ * @return Selected options, trimmed, with blanks removed.
+ */
+const toSelectionList = ( value: unknown ): string[] => {
+ const raw = Array.isArray( value ) ? value : [ value ];
+ const list: string[] = [];
+ raw.forEach( item => {
+ const text = toComparableString( item ).trim();
+ if ( '' !== text ) {
+ list.push( text );
+ }
+ } );
+ return list;
+};
+
+/**
+ * Parse both sides of a numeric comparison.
+ *
+ * @param actual - The submitted value.
+ * @param expected - The value configured on the rule.
+ * @return Both sides as numbers, or null when either side is not numeric.
+ */
+/**
+ * The selected value of a rating, dropping the scale it was submitted with.
+ *
+ * @param value - The submitted value, `selected/max` or a bare number.
+ * @return The selected part, for numeric comparison.
+ */
+const toRatingValue = ( value: unknown ): string => {
+ const text = toComparableString( value ).trim();
+ const slash = text.indexOf( '/' );
+
+ return slash === -1 ? text : text.slice( 0, slash );
+};
+
+const toNumericPair = ( actual: unknown, expected: unknown ): [ number, number ] | null => {
+ const left = toComparableString( actual ).trim();
+ const right = toComparableString( expected ).trim();
+ if ( '' === left || '' === right ) {
+ return null;
+ }
+ const leftNumber = Number( left );
+ const rightNumber = Number( right );
+ if ( ! Number.isFinite( leftNumber ) || ! Number.isFinite( rightNumber ) ) {
+ return null;
+ }
+ return [ leftNumber, rightNumber ];
+};
+
+/**
+ * Parse both sides of a date or time comparison into comparable numbers.
+ *
+ * Times are compared as minutes since midnight so a bare `HH:MM` needs no date context.
+ *
+ * @param actual - The submitted value.
+ * @param expected - The value configured on the rule.
+ * @param typeKey - Either `date` or `time`.
+ * @return Both sides as numbers, or null when either side cannot be parsed.
+ */
+/**
+ * Parse a date into a comparable YYYYMMDD integer.
+ *
+ * Deliberately not Date.parse(). It reads a bare `YYYY-MM-DD` as UTC but `mm/dd/yy` as local
+ * time, so PHP's site-local reading and this one disagreed by the visitor's UTC offset: `is`
+ * was false here while `after` was true on the server. A `dd/mm/yy` field was worse -- neither
+ * engine could read `31/12/2026`, so a show-rule hid its field permanently and the answer was
+ * dropped at storage.
+ *
+ * The field's own format decides how to read the value, the same way the datepicker wrote it.
+ * A rule's value always arrives as ISO, since the rule builder uses a native date input.
+ *
+ * @param text - Date text.
+ * @param format - Field date format: `mm/dd/yy`, `dd/mm/yy` or `yy-mm-dd`.
+ * @return YYYYMMDD, or null when the text does not match.
+ */
+const parseDate = ( text: string, format: string ): number | null => {
+ const toInt = ( year: number, month: number, day: number ): number | null =>
+ month < 1 || month > 12 || day < 1 || day > 31 ? null : year * 10000 + month * 100 + day;
+
+ // ISO first: the rule side is always ISO, and it is the default field format.
+ const iso = text.match( /^(\d{4})-(\d{1,2})-(\d{1,2})$/ );
+ if ( iso ) {
+ return toInt( Number( iso[ 1 ] ), Number( iso[ 2 ] ), Number( iso[ 3 ] ) );
+ }
+
+ const parts = text.match( /^(\d{1,4})[/.-](\d{1,2})[/.-](\d{1,4})$/ );
+ if ( ! parts ) {
+ return null;
+ }
+
+ const [ , a, b, c ] = parts.map( Number ) as unknown as number[];
+
+ // jQuery UI tokens, as used by the field: `yy` is the four-digit year.
+ switch ( format ) {
+ case 'dd/mm/yy':
+ return toInt( c, b, a );
+ case 'mm/dd/yy':
+ return toInt( c, a, b );
+ default:
+ return toInt( a, b, c );
+ }
+};
+
+const toTemporalPair = (
+ actual: unknown,
+ expected: unknown,
+ typeKey: TypeKey,
+ format = ''
+): [ number, number ] | null => {
+ const parse = ( value: unknown, valueFormat: string ): number | null => {
+ const text = toComparableString( value ).trim();
+ if ( '' === text ) {
+ return null;
+ }
+ if ( 'time' === typeKey ) {
+ const match = text.match( /^(\d{1,2}):(\d{2})/ );
+ if ( ! match ) {
+ return null;
+ }
+ return Number( match[ 1 ] ) * 60 + Number( match[ 2 ] );
+ }
+ return parseDate( text, valueFormat );
+ };
+
+ // The submitted value is written in the field's format; a rule's value is always ISO.
+ const left = parse( actual, format );
+ const right = parse( expected, '' );
+ if ( left === null || right === null ) {
+ return null;
+ }
+ return [ left, right ];
+};
+
+/**
+ * Evaluate a single rule against the current values.
+ *
+ * @param rule - The rule to evaluate.
+ * @param typeKey - Comparison behavior of the rule's subject field.
+ * @param actual - The subject field's current value.
+ * @param format - The subject field's date format, when it has one.
+ * @return True or false, or null when the rule cannot be evaluated and must be ignored.
+ */
+const evaluateRuleValue = (
+ rule: Rule,
+ typeKey: TypeKey,
+ actual: unknown,
+ format = ''
+): boolean | null => {
+ const operator = rule.operator;
+ const needsValue = operatorNeedsValue( operator );
+ const expected = needsValue ? rule.value ?? '' : '';
+
+ // An operator that compares against something, given nothing to compare against, cannot
+ // say anything -- so the rule is ignored rather than evaluated against an empty string.
+ // Evaluating it would be worse than useless: `does_not_contain ''` is true of every value,
+ // so a half-written rule would quietly force its field visible. The editor already tells
+ // the author this rule is inert; this is what makes that true.
+ if ( needsValue && '' === toComparableString( expected ).trim() ) {
+ return null;
+ }
+
+ switch ( operator ) {
+ case OPERATORS.IS_EMPTY:
+ return isEmptyValue( actual );
+ case OPERATORS.IS_NOT_EMPTY:
+ return ! isEmptyValue( actual );
+ case OPERATORS.IS_CHECKED:
+ return ! isEmptyValue( actual );
+ case OPERATORS.IS_NOT_CHECKED:
+ return isEmptyValue( actual );
+ default:
+ break;
+ }
+
+ if ( 'multichoice' === typeKey ) {
+ const selection = toSelectionList( actual );
+ const target = toComparableString( expected ).trim();
+ switch ( operator ) {
+ case OPERATORS.CONTAINS:
+ return selection.includes( target );
+ case OPERATORS.DOES_NOT_CONTAIN:
+ return ! selection.includes( target );
+ default:
+ return null;
+ }
+ }
+
+ if ( 'number' === typeKey || 'rating' === typeKey ) {
+ // A rating submits `selected/max`, e.g. `4/5`. The rule stores the bare number, so
+ // only the submitted side needs unpacking -- without it is_numeric/Number see `4/5`,
+ // every comparison returns false, and a rating rule can never match.
+ const submitted = 'rating' === typeKey ? toRatingValue( actual ) : actual;
+ const pair = toNumericPair( submitted, expected );
+ if ( pair === null ) {
+ return false;
+ }
+ const [ left, right ] = pair;
+ switch ( operator ) {
+ case OPERATORS.EQUALS:
+ return left === right;
+ case OPERATORS.NOT_EQUALS:
+ return left !== right;
+ case OPERATORS.GREATER_THAN:
+ return left > right;
+ case OPERATORS.LESS_THAN:
+ return left < right;
+ case OPERATORS.GTE:
+ return left >= right;
+ case OPERATORS.LTE:
+ return left <= right;
+ default:
+ return null;
+ }
+ }
+
+ if ( 'date' === typeKey || 'time' === typeKey ) {
+ const pair = toTemporalPair( actual, expected, typeKey, format );
+ if ( pair === null ) {
+ return false;
+ }
+ const [ left, right ] = pair;
+ switch ( operator ) {
+ case OPERATORS.IS:
+ return left === right;
+ case OPERATORS.IS_NOT:
+ return left !== right;
+ case OPERATORS.BEFORE:
+ return left < right;
+ case OPERATORS.AFTER:
+ return left > right;
+ default:
+ return null;
+ }
+ }
+
+ // string, choice, hidden and file all compare textually.
+ const left = toComparableString( actual );
+ const right = toComparableString( expected );
+ switch ( operator ) {
+ case OPERATORS.IS:
+ return left === right;
+ case OPERATORS.IS_NOT:
+ return left !== right;
+ case OPERATORS.CONTAINS:
+ return '' !== right && left.includes( right );
+ case OPERATORS.DOES_NOT_CONTAIN:
+ return '' === right || ! left.includes( right );
+ default:
+ return null;
+ }
+};
+
+/**
+ * Evaluate a field's conditional logic.
+ *
+ * A rule whose subject field is absent from `fieldTypes` is ignored rather than compared
+ * against an empty value, so deleting an unrelated block cannot silently hide a field.
+ * When every rule is ignored the field stays visible.
+ *
+ * @param logic - The field's conditional-logic config.
+ * @param fieldTypes - Map of field id to shortcode field type, for every field in the form.
+ * @param values - Map of field id to current value.
+ * @param fieldFormats - Map of field id to date format, for date fields.
+ * @return True when the field should be visible.
+ */
+export const evaluateLogic = (
+ logic: ConditionalLogic | null | undefined,
+ fieldTypes: Record< string, string >,
+ values: FormValues,
+ fieldFormats: Record< string, string > = {}
+): boolean => {
+ if ( ! logic || ! logic.enabled ) {
+ return true;
+ }
+
+ const groups = Array.isArray( logic.groups ) ? logic.groups : [];
+ if ( 0 === groups.length ) {
+ return true;
+ }
+
+ // Each group reduces its own rules with its own operator; the groups then reduce with the
+ // top-level one. With a single group -- all the V1 panel writes -- the outer reduction is
+ // a no-op, so this behaves exactly as a flat rule list until a second group exists.
+ const groupOutcomes: boolean[] = [];
+
+ groups.forEach( group => {
+ const rules = Array.isArray( group?.rules ) ? group.rules : [];
+ const outcomes: boolean[] = [];
+
+ rules.forEach( rule => {
+ if ( ! rule || ! rule.field || ! rule.operator ) {
+ return;
+ }
+ if ( rule.type && rule.type !== RULE_TYPE_FIELD_VALUE ) {
+ return; // A condition kind this release does not know — ignore it.
+ }
+ if ( ! ( rule.field in fieldTypes ) ) {
+ return; // Subject field no longer exists — ignore this rule.
+ }
+ const typeKey = getTypeKeyForFieldType( fieldTypes[ rule.field ] );
+ // A date field's value is written in its own format, so the comparison needs it.
+ const outcome = evaluateRuleValue(
+ rule,
+ typeKey,
+ values[ rule.field ],
+ fieldFormats[ rule.field ] ?? ''
+ );
+ if ( outcome !== null ) {
+ outcomes.push( outcome );
+ }
+ } );
+
+ // A group with nothing evaluable is ignored, the same way a single unusable rule is,
+ // so deleting a subject field cannot silently hide the field that referenced it.
+ if ( outcomes.length ) {
+ groupOutcomes.push(
+ 'all' === group?.logicalOperator ? outcomes.every( Boolean ) : outcomes.some( Boolean )
+ );
+ }
+ } );
+
+ if ( 0 === groupOutcomes.length ) {
+ return true;
+ }
+
+ const matched =
+ 'all' === logic.logicalOperator
+ ? groupOutcomes.every( Boolean )
+ : groupOutcomes.some( Boolean );
+
+ return 'hide' === logic.action ? ! matched : matched;
+};
+
+/**
+ * Resolve visibility for every field in a form at once.
+ *
+ * Runs to a fixed point so a hidden field's value reads as empty for everyone else: if the
+ * question was never asked, its answer must not satisfy another field's condition. On
+ * ambiguity — circular rules, or passes exhausted — the field is left visible, because a
+ * stray value in a response is recoverable and a silently discarded answer is not.
+ *
+ * @param fields - Map of field id to its logic and comparison behavior.
+ * @param values - Map of field id to submitted value.
+ * @return Map of field id to visibility.
+ */
+export const resolveVisibility = (
+ fields: Record< string, FieldDescriptor >,
+ values: FormValues
+): Record< string, boolean > => {
+ const ids = Object.keys( fields );
+ const visible: Record< string, boolean > = {};
+ ids.forEach( id => {
+ visible[ id ] = true;
+ } );
+
+ const withLogic = ids.filter( id => fields[ id ]?.logic?.enabled );
+ if ( 0 === withLogic.length ) {
+ return visible;
+ }
+
+ const fieldTypes: Record< string, string > = {};
+ const fieldFormats: Record< string, string > = {};
+ ids.forEach( id => {
+ fieldTypes[ id ] = fields[ id ].type;
+ if ( fields[ id ].format ) {
+ fieldFormats[ id ] = fields[ id ].format as string;
+ }
+ } );
+
+ // One pass per conditional field, plus one to confirm nothing moved. Not clamped to a
+ // constant: that made an acyclic chain deeper than the clamp read as circular and fail
+ // open. The field count is what guarantees convergence, and it cannot exceed the form.
+ const maxPasses = withLogic.length + 1;
+
+ // Fields that change after the opening pass are reacting to another field's change, which
+ // is the signature of an oscillation. Collected across every pass, because a participant in
+ // a cycle need not be the one that happened to flip on the final pass.
+ const unstable = new Set< string >();
+
+ for ( let pass = 0; pass < maxPasses; pass++ ) {
+ const effective: FormValues = {};
+ ids.forEach( id => {
+ effective[ id ] = visible[ id ] ? values[ id ] : '';
+ } );
+
+ let changedCount = 0;
+ withLogic.forEach( id => {
+ const next = evaluateLogic( fields[ id ].logic, fieldTypes, effective, fieldFormats );
+ if ( next !== visible[ id ] ) {
+ visible[ id ] = next;
+ changedCount++;
+ if ( pass > 0 ) {
+ unstable.add( id );
+ }
+ }
+ } );
+
+ if ( 0 === changedCount ) {
+ return visible; // Fixed point.
+ }
+ }
+
+ // Passes exhausted, so the rules are circular. Fail open for everything caught in the cycle.
+ unstable.forEach( id => {
+ visible[ id ] = true;
+ } );
+
+ return visible;
+};
diff --git a/projects/packages/forms/src/blocks/shared/conditional-logic/util/field-label.js b/projects/packages/forms/src/blocks/shared/conditional-logic/util/field-label.js
new file mode 100644
index 000000000000..ba0855cc38e6
--- /dev/null
+++ b/projects/packages/forms/src/blocks/shared/conditional-logic/util/field-label.js
@@ -0,0 +1,29 @@
+import { __, sprintf } from '@wordpress/i18n';
+
+/**
+ * How a subject field is named wherever a rule refers to it.
+ *
+ * The block type comes along in brackets — `Name (Name field)` — because a field's label is
+ * not reliably distinguishing: most carry no explicit id, several may share a label, and one
+ * with no label at all falls back to "Untitled field". The type is what tells them apart.
+ *
+ * Shared by the subject dropdown and the inspector summary so the same field cannot be named
+ * two different ways in the same panel.
+ *
+ * @param {object} field - Subject field descriptor, from useSubjectFields.
+ * @return {string} The field's label, with its block type when there is one.
+ */
+export const getFieldDisplayName = field => {
+ const label = field?.label || '';
+
+ if ( ! field?.typeLabel ) {
+ return label;
+ }
+
+ return sprintf(
+ /* translators: 1: form field label, 2: the field's type, e.g. "Dropdown field" */
+ __( '%1$s (%2$s)', 'jetpack-forms' ),
+ label,
+ field.typeLabel
+ );
+};
diff --git a/projects/packages/forms/src/blocks/shared/conditional-logic/util/field-options.ts b/projects/packages/forms/src/blocks/shared/conditional-logic/util/field-options.ts
new file mode 100644
index 000000000000..bcfdf3c8cecf
--- /dev/null
+++ b/projects/packages/forms/src/blocks/shared/conditional-logic/util/field-options.ts
@@ -0,0 +1,110 @@
+/*
+ * Normalizes the several ways Jetpack form fields store their selectable options, so the
+ * conditional-logic rule builder can offer a value dropdown regardless of field type.
+ *
+ * Three schemes exist in the package:
+ *
+ * 1. `jetpack/field-select` keeps a plain string array in its `options` attribute.
+ * 2. `jetpack/field-single-choice` and `jetpack/field-multiple-choice` use `jetpack/option`
+ * inner blocks, normally nested one level down under a `jetpack/options` wrapper.
+ * 3. `jetpack/field-image-select` uses `jetpack/input-image-option` inner blocks under a
+ * `jetpack/fieldset-image-options` wrapper.
+ */
+
+type MinimalBlock = {
+ name?: string;
+ attributes?: Record< string, unknown >;
+ innerBlocks?: MinimalBlock[];
+};
+
+export type FieldOption = {
+ value: string;
+ label: string;
+};
+
+/**
+ * Inner block names that carry a selectable option label.
+ */
+const OPTION_BLOCK_NAMES = [ 'jetpack/option', 'jetpack/input-image-option' ];
+
+/**
+ * Reduce a list of raw labels to unique, non-blank options in first-seen order.
+ *
+ * @param labels - Raw label values, possibly blank, padded or duplicated.
+ * @return Normalized option list.
+ */
+const toOptions = ( labels: unknown[] ): FieldOption[] => {
+ const seen = new Set< string >();
+ const options: FieldOption[] = [];
+
+ labels.forEach( raw => {
+ if ( typeof raw !== 'string' ) {
+ return;
+ }
+ const label = raw.trim();
+ if ( '' === label || seen.has( label ) ) {
+ return;
+ }
+ seen.add( label );
+ options.push( { value: label, label } );
+ } );
+
+ return options;
+};
+
+/**
+ * Recursively collect option labels from a block's descendants.
+ *
+ * @param blocks - Blocks to walk.
+ * @param labels - Accumulator for discovered labels.
+ */
+const collectOptionLabels = ( blocks: MinimalBlock[] | undefined, labels: unknown[] ): void => {
+ if ( ! Array.isArray( blocks ) ) {
+ return;
+ }
+
+ blocks.forEach( block => {
+ if ( ! block ) {
+ return;
+ }
+ if ( block.name && OPTION_BLOCK_NAMES.includes( block.name ) ) {
+ labels.push( block.attributes?.label );
+ return;
+ }
+ collectOptionLabels( block.innerBlocks, labels );
+ } );
+};
+
+/**
+ * Resolve the options a field offers, for use as conditional-logic rule values.
+ *
+ * @param block - The field block instance, as returned by `getBlock()`.
+ * @return Option list; empty for field types that have no fixed options.
+ */
+export const getFieldOptions = ( block?: MinimalBlock | null ): FieldOption[] => {
+ if ( ! block ) {
+ return [];
+ }
+
+ // A rating has no option blocks: its choices are its own scale, so they are derived from
+ // the configured maximum. Offering 1..max also keeps an author from writing a rule
+ // against 6 stars out of 5, which could never match.
+ if ( 'jetpack/field-rating' === block.name ) {
+ const max = Number( block.attributes?.max );
+ const steps = Number.isFinite( max ) && max > 0 ? Math.floor( max ) : 5;
+
+ return Array.from( { length: steps }, ( _, index ) => ( {
+ value: String( index + 1 ),
+ label: String( index + 1 ),
+ } ) );
+ }
+
+ if ( 'jetpack/field-select' === block.name ) {
+ const options = block.attributes?.options;
+ return Array.isArray( options ) ? toOptions( options ) : [];
+ }
+
+ const labels: unknown[] = [];
+ collectOptionLabels( block.innerBlocks, labels );
+ return toOptions( labels );
+};
diff --git a/projects/packages/forms/src/blocks/shared/conditional-logic/util/field-types.ts b/projects/packages/forms/src/blocks/shared/conditional-logic/util/field-types.ts
new file mode 100644
index 000000000000..484a0af7b1d5
--- /dev/null
+++ b/projects/packages/forms/src/blocks/shared/conditional-logic/util/field-types.ts
@@ -0,0 +1,196 @@
+/*
+ * Kept free of `@wordpress/i18n` on purpose: this module is imported by the front-end
+ * form runtime, which builds as a WordPress script module, and script modules cannot
+ * import that package yet. Translated operator labels live in ./operator-labels.ts,
+ * which only the editor loads.
+ */
+
+/**
+ * Operator wire strings shared with the PHP evaluator.
+ *
+ * This object is the source of truth: `Conditional_Logic`'s `OP_*` constants must
+ * match these values exactly, and `Conditional_Logic_Parity_Test` parses this file
+ * to enforce that. Changing a value here is a breaking change to stored rules.
+ */
+export const OPERATORS = {
+ IS: 'is',
+ IS_NOT: 'is_not',
+ CONTAINS: 'contains',
+ DOES_NOT_CONTAIN: 'does_not_contain',
+ IS_EMPTY: 'is_empty',
+ IS_NOT_EMPTY: 'is_not_empty',
+ EQUALS: 'equals',
+ NOT_EQUALS: 'not_equals',
+ GREATER_THAN: 'greater_than',
+ LESS_THAN: 'less_than',
+ GTE: 'gte',
+ LTE: 'lte',
+ BEFORE: 'before',
+ AFTER: 'after',
+ IS_CHECKED: 'is_checked',
+ IS_NOT_CHECKED: 'is_not_checked',
+} as const;
+
+export type Operator = ( typeof OPERATORS )[ keyof typeof OPERATORS ];
+
+/**
+ * A field's comparison behavior, derived from its block type. Several blocks share a
+ * key: a select and a radio group both compare against a fixed option list. A rating
+ * compares numerically but is not a `number`: it submits `selected/max`, e.g. `4/5`, and
+ * offers its own scale as the values to compare against.
+ */
+export type TypeKey =
+ | 'string'
+ | 'choice'
+ | 'multichoice'
+ | 'number'
+ | 'date'
+ | 'time'
+ | 'boolean'
+ | 'hidden'
+ | 'file'
+ | 'rating';
+
+export type ValueInputKind = 'text' | 'options' | 'number' | 'date' | 'time' | 'none';
+
+/**
+ * Operators that compare a field against nothing, so the UI renders no value input and
+ * the evaluators ignore `rule.value` entirely.
+ */
+const OPERATORS_WITHOUT_VALUE: Set< string > = new Set( [
+ OPERATORS.IS_EMPTY,
+ OPERATORS.IS_NOT_EMPTY,
+ OPERATORS.IS_CHECKED,
+ OPERATORS.IS_NOT_CHECKED,
+] );
+
+/**
+ * Front-end and submission-side lookup: shortcode `type` to comparison behavior.
+ *
+ * Fields flatten to `[contact-field type="…"]` before rendering, so the browser runtime and
+ * PHP see these strings rather than block names. Mirrored by
+ * `Conditional_Logic::TYPE_KEY_BY_FIELD_TYPE`.
+ *
+ * `field-telephone` emits `telephone` or `phone` depending on its country-selector setting,
+ * so both appear here.
+ */
+export const TYPE_KEY_BY_FIELD_TYPE: Record< string, TypeKey > = {
+ text: 'string',
+ name: 'string',
+ email: 'string',
+ url: 'string',
+ textarea: 'string',
+ telephone: 'string',
+ phone: 'string',
+ select: 'choice',
+ radio: 'choice',
+ 'image-select': 'choice',
+ 'checkbox-multiple': 'multichoice',
+ number: 'number',
+ slider: 'number',
+ rating: 'rating',
+ date: 'date',
+ time: 'time',
+ checkbox: 'boolean',
+ consent: 'boolean',
+ hidden: 'hidden',
+ file: 'file',
+};
+
+const OPERATORS_BY_TYPE_KEY: Record< TypeKey, Operator[] > = {
+ string: [
+ OPERATORS.IS,
+ OPERATORS.IS_NOT,
+ OPERATORS.CONTAINS,
+ OPERATORS.DOES_NOT_CONTAIN,
+ OPERATORS.IS_EMPTY,
+ OPERATORS.IS_NOT_EMPTY,
+ ],
+ choice: [ OPERATORS.IS, OPERATORS.IS_NOT, OPERATORS.IS_EMPTY, OPERATORS.IS_NOT_EMPTY ],
+ multichoice: [
+ OPERATORS.CONTAINS,
+ OPERATORS.DOES_NOT_CONTAIN,
+ OPERATORS.IS_EMPTY,
+ OPERATORS.IS_NOT_EMPTY,
+ ],
+ rating: [
+ OPERATORS.EQUALS,
+ OPERATORS.NOT_EQUALS,
+ OPERATORS.GREATER_THAN,
+ OPERATORS.LESS_THAN,
+ OPERATORS.GTE,
+ OPERATORS.LTE,
+ OPERATORS.IS_EMPTY,
+ OPERATORS.IS_NOT_EMPTY,
+ ],
+ number: [
+ OPERATORS.EQUALS,
+ OPERATORS.NOT_EQUALS,
+ OPERATORS.GREATER_THAN,
+ OPERATORS.LESS_THAN,
+ OPERATORS.GTE,
+ OPERATORS.LTE,
+ OPERATORS.IS_EMPTY,
+ OPERATORS.IS_NOT_EMPTY,
+ ],
+ date: [ OPERATORS.IS, OPERATORS.IS_NOT, OPERATORS.BEFORE, OPERATORS.AFTER ],
+ time: [ OPERATORS.IS, OPERATORS.IS_NOT, OPERATORS.BEFORE, OPERATORS.AFTER ],
+ boolean: [ OPERATORS.IS_CHECKED, OPERATORS.IS_NOT_CHECKED ],
+ hidden: [ OPERATORS.IS, OPERATORS.IS_NOT, OPERATORS.CONTAINS ],
+ file: [ OPERATORS.IS_EMPTY, OPERATORS.IS_NOT_EMPTY ],
+};
+
+const VALUE_INPUT_BY_TYPE_KEY: Record< TypeKey, ValueInputKind > = {
+ string: 'text',
+ choice: 'options',
+ multichoice: 'options',
+ number: 'number',
+ // The field carries its own scale, so the rule builder lists 1..max rather than a free
+ // number box that would accept 6 stars out of 5.
+ rating: 'options',
+ date: 'date',
+ time: 'time',
+ boolean: 'none',
+ hidden: 'text',
+ file: 'none',
+};
+
+/**
+ * Resolve a shortcode field type to its comparison behavior.
+ *
+ * @param fieldType - The shortcode `type` attribute, e.g. `checkbox-multiple`.
+ * @return The type key; unknown types compare textually rather than being dropped.
+ */
+export const getTypeKeyForFieldType = ( fieldType?: string ): TypeKey => {
+ if ( ! fieldType ) {
+ return 'string';
+ }
+ return TYPE_KEY_BY_FIELD_TYPE[ fieldType ] ?? 'string';
+};
+
+/**
+ * Operators offered for a given comparison behavior.
+ *
+ * @param typeKey - The field's type key.
+ * @return Ordered operator list; empty for an unrecognized key.
+ */
+export const getOperatorsForTypeKey = ( typeKey: TypeKey | string ): Operator[] =>
+ OPERATORS_BY_TYPE_KEY[ typeKey as TypeKey ] ?? [];
+
+/**
+ * Which value control the rule builder should render for a comparison behavior.
+ *
+ * @param typeKey - The field's type key.
+ * @return The value input kind; falls back to a plain text box.
+ */
+export const getValueInputForTypeKey = ( typeKey: TypeKey | string ): ValueInputKind =>
+ VALUE_INPUT_BY_TYPE_KEY[ typeKey as TypeKey ] ?? 'text';
+
+/**
+ * Whether an operator compares against a value the author must supply.
+ *
+ * @param operator - The operator wire string.
+ * @return True when a value input is required.
+ */
+export const operatorNeedsValue = ( operator: Operator | string ): boolean =>
+ ! OPERATORS_WITHOUT_VALUE.has( operator );
diff --git a/projects/packages/forms/src/blocks/shared/conditional-logic/util/operator-labels.ts b/projects/packages/forms/src/blocks/shared/conditional-logic/util/operator-labels.ts
new file mode 100644
index 000000000000..987e32e9ba10
--- /dev/null
+++ b/projects/packages/forms/src/blocks/shared/conditional-logic/util/operator-labels.ts
@@ -0,0 +1,42 @@
+/*
+ * Translated operator labels for the conditional-logic rule builder.
+ *
+ * Separate from ./field-types.ts because that module is shared with the front-end form
+ * runtime, which builds as a WordPress script module and cannot import `@wordpress/i18n`.
+ * Only the editor imports this file.
+ */
+
+import { __ } from '@wordpress/i18n';
+import { OPERATORS } from './field-types';
+import type { Operator } from './field-types';
+
+/**
+ * Human-readable label for every operator, keyed by wire string.
+ */
+export const OPERATOR_LABELS: Record< Operator, string > = {
+ [ OPERATORS.IS ]: __( 'is', 'jetpack-forms' ),
+ [ OPERATORS.IS_NOT ]: __( 'is not', 'jetpack-forms' ),
+ [ OPERATORS.CONTAINS ]: __( 'contains', 'jetpack-forms' ),
+ [ OPERATORS.DOES_NOT_CONTAIN ]: __( 'does not contain', 'jetpack-forms' ),
+ [ OPERATORS.IS_EMPTY ]: __( 'is empty', 'jetpack-forms' ),
+ [ OPERATORS.IS_NOT_EMPTY ]: __( 'is not empty', 'jetpack-forms' ),
+ [ OPERATORS.EQUALS ]: __( 'equals', 'jetpack-forms' ),
+ [ OPERATORS.NOT_EQUALS ]: __( 'does not equal', 'jetpack-forms' ),
+ [ OPERATORS.GREATER_THAN ]: __( 'is greater than', 'jetpack-forms' ),
+ [ OPERATORS.LESS_THAN ]: __( 'is less than', 'jetpack-forms' ),
+ [ OPERATORS.GTE ]: __( 'is at least', 'jetpack-forms' ),
+ [ OPERATORS.LTE ]: __( 'is at most', 'jetpack-forms' ),
+ [ OPERATORS.BEFORE ]: __( 'is before', 'jetpack-forms' ),
+ [ OPERATORS.AFTER ]: __( 'is after', 'jetpack-forms' ),
+ [ OPERATORS.IS_CHECKED ]: __( 'is checked', 'jetpack-forms' ),
+ [ OPERATORS.IS_NOT_CHECKED ]: __( 'is not checked', 'jetpack-forms' ),
+};
+
+/**
+ * Label for an operator, falling back to the raw wire string for forward compatibility.
+ *
+ * @param operator - The operator wire string.
+ * @return Translated label.
+ */
+export const getOperatorLabel = ( operator: Operator | string ): string =>
+ OPERATOR_LABELS[ operator as Operator ] ?? operator;
diff --git a/projects/packages/forms/src/blocks/shared/conditional-logic/util/rule-validity.js b/projects/packages/forms/src/blocks/shared/conditional-logic/util/rule-validity.js
new file mode 100644
index 000000000000..9f7851cef80b
--- /dev/null
+++ b/projects/packages/forms/src/blocks/shared/conditional-logic/util/rule-validity.js
@@ -0,0 +1,51 @@
+import { operatorNeedsValue } from './field-types.ts';
+
+/**
+ * Whether the author has begun this condition at all.
+ *
+ * A row with no subject chosen is empty rather than wrong, so nothing complains about it —
+ * the builder opens with one of these waiting to be filled in.
+ *
+ * @param {object} rule - The rule to check.
+ * @return {boolean} True once a subject field has been chosen.
+ */
+export const isRuleStarted = rule => Boolean( rule?.field );
+
+/**
+ * Whether a condition says something the evaluator can act on.
+ *
+ * This mirrors what the evaluators actually skip: a rule with no subject, or one whose
+ * operator needs a value it has not been given, is ignored at submit time. Without a signal in
+ * the editor that reads as the field simply not reacting, with nothing on screen explaining
+ * why -- so the same judgement is made here, where it can be shown.
+ *
+ * @param {object} rule - The rule to check.
+ * @param {object} subject - The rule's subject field, or undefined when it no longer exists.
+ * @return {boolean} True when the condition is complete.
+ */
+export const isRuleComplete = ( rule, subject ) => {
+ if ( ! isRuleStarted( rule ) || ! subject || ! rule.operator ) {
+ return false;
+ }
+
+ // `is empty`, `is checked` and friends compare nothing, so there is nothing to fill in.
+ if ( ! operatorNeedsValue( rule.operator ) ) {
+ return true;
+ }
+
+ // Anything else needs something to compare against. Deliberately no exception for subjects
+ // that render no value input: an operator needing a value it cannot be given is exactly as
+ // inert as one the author simply has not filled in, and both evaluators skip it. Treating
+ // it as complete here would put the icon back at odds with what actually happens.
+ return '' !== String( rule.value ?? '' ).trim();
+};
+
+/**
+ * Whether every condition in the list is complete.
+ *
+ * @param {Array} rules - The rules to check.
+ * @param {Function} findSubject - Resolves a rule's subject field.
+ * @return {boolean} True when no condition is left unfinished.
+ */
+export const areRulesComplete = ( rules, findSubject ) =>
+ rules.every( rule => isRuleComplete( rule, findSubject( rule ) ) );
diff --git a/projects/packages/forms/src/blocks/shared/conditional-logic/util/summary.js b/projects/packages/forms/src/blocks/shared/conditional-logic/util/summary.js
new file mode 100644
index 000000000000..6671a81bfe29
--- /dev/null
+++ b/projects/packages/forms/src/blocks/shared/conditional-logic/util/summary.js
@@ -0,0 +1,115 @@
+import { __, sprintf } from '@wordpress/i18n';
+import { getFieldDisplayName } from './field-label.js';
+import { operatorNeedsValue } from './field-types.ts';
+import { getOperatorLabel } from './operator-labels.ts';
+import { isRuleComplete } from './rule-validity.js';
+
+/**
+ * The line introducing a field's conditions in the inspector.
+ *
+ * Four separate strings rather than one assembled from fragments: "shown"/"hidden" and
+ * "all"/"any" do not slot into every language the same way, and a sentence built by
+ * concatenation cannot be reordered by a translator.
+ *
+ * @param {object} logic - A normalized logic object.
+ * @param {object} group - The group being described.
+ * @return {string} A sentence ending in a colon.
+ */
+export const getSummaryHeading = ( logic, group ) => {
+ const matchesAll = 'all' === group.logicalOperator;
+
+ // The trailing 0 on one branch of each pair is deliberate, and matches how this is handled
+ // elsewhere in the package: two identically shaped __() calls in a ternary get folded by
+ // the production minifier into __( cond ? 'a' : 'b', domain ), whose msgid is no longer a
+ // literal and so cannot be extracted for translation. It is ignored at runtime.
+ if ( 'hide' === logic.action ) {
+ return matchesAll
+ ? __( 'This field is hidden only if:', 'jetpack-forms' )
+ : __( 'This field is hidden if any of these are true:', 'jetpack-forms', 0 );
+ }
+
+ return matchesAll
+ ? __( 'This field is shown only if:', 'jetpack-forms' )
+ : __( 'This field is shown if any of these are true:', 'jetpack-forms', 0 );
+};
+
+/**
+ * One condition, in the words the rule builder uses for it.
+ *
+ * Reads as the sentence the author built — subject, comparison, value — so the inspector says
+ * what the field actually does rather than how many rules it has.
+ *
+ * @param {object} rule - The rule to describe.
+ * @param {object} subject - The rule's subject field descriptor.
+ * @return {string} A phrase such as `Phone is “iPhone”`.
+ */
+export const describeRule = ( rule, subject ) => {
+ // Named the same way the subject dropdown names it, type in brackets and all: a label on
+ // its own does not always identify a field, and reading one name in the summary and
+ // another in the builder would make them look like different fields.
+ const label = getFieldDisplayName( subject );
+ const operator = getOperatorLabel( rule.operator );
+
+ // `is empty`, `is checked` and friends compare against nothing, so there is nothing to
+ // quote after them.
+ if ( ! operatorNeedsValue( rule.operator ) ) {
+ return sprintf(
+ /* translators: 1: form field label, 2: comparison, e.g. "is not empty" */
+ __( '%1$s %2$s', 'jetpack-forms' ),
+ label,
+ operator
+ );
+ }
+
+ return sprintf(
+ /* translators: 1: form field label, 2: comparison, e.g. "is", 3: the value compared against */
+ __( '%1$s %2$s “%3$s”', 'jetpack-forms' ),
+ label,
+ operator,
+ String( rule.value ?? '' )
+ );
+};
+
+/**
+ * The conditions worth listing: the ones that will actually be acted on.
+ *
+ * An incomplete rule is skipped by both evaluators, so listing it in the summary would
+ * describe behaviour the field does not have.
+ *
+ * @param {object} group - The group being described.
+ * @param {Array} fields - Subject field descriptors, from useSubjectFields.
+ * @return {Array} Objects of `{ rule, subject }` for each condition that will be acted on.
+ */
+export const getActiveConditions = ( group, fields ) =>
+ group.rules
+ .map( rule => ( {
+ rule,
+ subject: fields.find( field => field.id && field.id === rule.field ),
+ } ) )
+ .filter( ( { rule, subject } ) => isRuleComplete( rule, subject ) );
+
+/**
+ * The same summary on one line, for somewhere a list will not fit.
+ *
+ * The toolbar button's tooltip, specifically. It says the same thing the inspector does so the
+ * two cannot drift, just without the markup.
+ *
+ * @param {object} logic - A normalized logic object.
+ * @param {object} group - The group being described.
+ * @param {Array} fields - Subject field descriptors, from useSubjectFields.
+ * @return {string} A single-line summary, or an empty string when nothing is active.
+ */
+export const getSummaryText = ( logic, group, fields ) => {
+ const active = getActiveConditions( group, fields );
+
+ if ( ! active.length ) {
+ return '';
+ }
+
+ return sprintf(
+ /* translators: 1: heading ending in a colon, 2: the conditions, separated by semicolons */
+ __( '%1$s %2$s', 'jetpack-forms' ),
+ getSummaryHeading( logic, group ),
+ active.map( ( { rule, subject } ) => describeRule( rule, subject ) ).join( '; ' )
+ );
+};
diff --git a/projects/packages/forms/src/blocks/shared/settings/index.js b/projects/packages/forms/src/blocks/shared/settings/index.js
index e597ce2add04..7e0f7d044117 100644
--- a/projects/packages/forms/src/blocks/shared/settings/index.js
+++ b/projects/packages/forms/src/blocks/shared/settings/index.js
@@ -20,6 +20,20 @@ export default {
type: 'boolean',
default: true,
},
+ conditionalLogic: {
+ type: 'object',
+ default: {
+ enabled: false,
+ action: 'show',
+ // Combines the groups with each other; each group combines its own rules.
+ logicalOperator: 'any',
+ // An array, not a map: a map cannot express "any of these AND all of those",
+ // which is where this is heading. The V1 panel writes one group, so showing a
+ // second one later is a panel change rather than a storage change. Rules carry
+ // their own type, so further condition kinds slot into a group instead.
+ groups: [],
+ },
+ },
},
category: 'contact-form',
providesContext: {
diff --git a/projects/packages/forms/src/class-jetpack-forms.php b/projects/packages/forms/src/class-jetpack-forms.php
index f1031fad1e80..f42d52966d13 100644
--- a/projects/packages/forms/src/class-jetpack-forms.php
+++ b/projects/packages/forms/src/class-jetpack-forms.php
@@ -7,6 +7,7 @@
namespace Automattic\Jetpack\Forms;
+use Automattic\Jetpack\Feature_Flags\Feature_Flags;
use Automattic\Jetpack\Forms\ContactForm\Feedback_Source;
use Automattic\Jetpack\Forms\ContactForm\Util;
use Automattic\Jetpack\Forms\Dashboard\Dashboard;
@@ -17,10 +18,37 @@ class Jetpack_Forms {
const PACKAGE_VERSION = '7.24.0';
+ /**
+ * Name of the feature flag gating field conditional logic.
+ */
+ const CONDITIONAL_LOGIC_FLAG = 'forms-conditional-logic';
+
+ /**
+ * Register the package's feature flags.
+ *
+ * Registration is unconditional and happens as the package loads, so the full set stays
+ * discoverable through `Feature_Flags::all()` and nothing can call `is_enabled()` on an
+ * unregistered flag.
+ *
+ * @return void
+ */
+ public static function register_feature_flags() {
+ Feature_Flags::register(
+ self::CONDITIONAL_LOGIC_FLAG,
+ array(
+ 'default' => false,
+ 'description' => 'Show or hide a form field based on the answer to another field.',
+ 'owner' => 'jetpack-forms',
+ )
+ );
+ }
+
/**
* Load the contact form module.
*/
public static function load_contact_form() {
+ self::register_feature_flags();
+
Util::init();
if ( self::is_feedback_dashboard_enabled() ) {
@@ -129,6 +157,22 @@ public static function is_integrations_enabled() {
return apply_filters( 'jetpack_forms_is_integrations_enabled', true );
}
+ /**
+ * Returns true if field conditional logic is enabled.
+ *
+ * One switch for the whole feature: the editor panel, the front-end show/hide, and the
+ * submission-time enforcement in validation and storage. Gating them together means a
+ * form can never hide a field from the visitor while still requiring it on submit.
+ *
+ * Turning the flag off on a form that already has conditions is safe: the conditions are
+ * simply ignored, so every field renders, validates and stores as an ordinary field.
+ *
+ * @return boolean
+ */
+ public static function is_conditional_logic_enabled() {
+ return Feature_Flags::is_enabled( self::CONDITIONAL_LOGIC_FLAG );
+ }
+
/**
* Returns true if webhooks are enabled.
*
diff --git a/projects/packages/forms/src/contact-form/class-conditional-logic.php b/projects/packages/forms/src/contact-form/class-conditional-logic.php
new file mode 100644
index 000000000000..4e64401c8458
--- /dev/null
+++ b/projects/packages/forms/src/contact-form/class-conditional-logic.php
@@ -0,0 +1,630 @@
+ 'string',
+ 'name' => 'string',
+ 'email' => 'string',
+ 'url' => 'string',
+ 'textarea' => 'string',
+ 'telephone' => 'string',
+ 'phone' => 'string',
+ 'select' => 'choice',
+ 'radio' => 'choice',
+ 'image-select' => 'choice',
+ 'checkbox-multiple' => 'multichoice',
+ 'number' => 'number',
+ 'slider' => 'number',
+ 'rating' => 'rating',
+ 'date' => 'date',
+ 'time' => 'time',
+ 'checkbox' => 'boolean',
+ 'consent' => 'boolean',
+ 'hidden' => 'hidden',
+ 'file' => 'file',
+ );
+
+ /**
+ * Operators that compare against nothing, so `value` is ignored.
+ *
+ * @var array
+ */
+ const OPERATORS_WITHOUT_VALUE = array(
+ self::OP_IS_EMPTY,
+ self::OP_IS_NOT_EMPTY,
+ self::OP_IS_CHECKED,
+ self::OP_IS_NOT_CHECKED,
+ );
+
+ /**
+ * Resolve a shortcode field type to its comparison behavior.
+ *
+ * @param string $field_type The shortcode `type` attribute.
+ *
+ * @return string The type key; unknown types compare textually rather than being dropped.
+ */
+ public static function type_key_for_field_type( $field_type ): string {
+ if ( ! is_string( $field_type ) || '' === $field_type ) {
+ return 'string';
+ }
+
+ return self::TYPE_KEY_BY_FIELD_TYPE[ $field_type ] ?? 'string';
+ }
+
+ /**
+ * Evaluate a field's conditional logic.
+ *
+ * A rule whose subject field is absent from `$field_types` is ignored rather than
+ * compared against an empty value, so deleting an unrelated block cannot silently hide a
+ * field. When every rule is ignored the field stays visible.
+ *
+ * @param array|null $logic The field's conditional-logic config.
+ * @param array $field_types Map of field id to shortcode type, for every form field.
+ * @param array $form_values Map of field id to submitted value.
+ * @param array $field_formats Map of field id to date format, for date fields.
+ *
+ * @return bool True when the field should be visible.
+ */
+ public static function evaluate( $logic, array $field_types, array $form_values, array $field_formats = array() ): bool {
+ if ( ! is_array( $logic ) || empty( $logic['enabled'] ) ) {
+ return true;
+ }
+
+ $groups = isset( $logic['groups'] ) && is_array( $logic['groups'] ) ? $logic['groups'] : array();
+
+ if ( empty( $groups ) ) {
+ return true;
+ }
+
+ // Each group reduces its own rules with its own operator; the groups then reduce with
+ // the top-level one. With a single group -- all the V1 panel writes -- the outer
+ // reduction is a no-op, so this behaves exactly as a flat rule list until a second
+ // group exists.
+ $group_outcomes = array();
+
+ foreach ( $groups as $group ) {
+ $rules = isset( $group['rules'] ) && is_array( $group['rules'] ) ? $group['rules'] : array();
+
+ $outcomes = array();
+ foreach ( $rules as $rule ) {
+ if ( ! is_array( $rule ) || empty( $rule['field'] ) || empty( $rule['operator'] ) ) {
+ continue;
+ }
+
+ // A condition kind this release does not know: ignore that rule, so a form
+ // saved by a newer editor degrades to its remaining conditions.
+ if ( ! empty( $rule['type'] ) && self::RULE_TYPE_FIELD_VALUE !== $rule['type'] ) {
+ continue;
+ }
+
+ $field_id = (string) $rule['field'];
+ if ( ! array_key_exists( $field_id, $field_types ) ) {
+ continue; // Subject field no longer exists: ignore this rule.
+ }
+
+ $type_key = self::type_key_for_field_type( $field_types[ $field_id ] );
+ $actual = array_key_exists( $field_id, $form_values ) ? $form_values[ $field_id ] : '';
+ // A date field's value is written in its own format, so the comparison needs it.
+ $format = isset( $field_formats[ $field_id ] ) ? (string) $field_formats[ $field_id ] : '';
+ $outcome = self::evaluate_rule_value( $rule, $type_key, $actual, $format );
+
+ if ( null !== $outcome ) {
+ $outcomes[] = $outcome;
+ }
+ }
+
+ // A group with nothing evaluable is ignored, the same way a single unusable rule
+ // is, so deleting a subject field cannot silently hide the field referencing it.
+ if ( empty( $outcomes ) ) {
+ continue;
+ }
+
+ $group_operator = $group['logicalOperator'] ?? 'any';
+ $group_outcomes[] = 'all' === $group_operator
+ ? ! in_array( false, $outcomes, true )
+ : in_array( true, $outcomes, true );
+ }
+
+ if ( empty( $group_outcomes ) ) {
+ return true;
+ }
+
+ $logical_operator = $logic['logicalOperator'] ?? 'any';
+
+ if ( 'all' === $logical_operator ) {
+ $matched = ! in_array( false, $group_outcomes, true );
+ } else {
+ $matched = in_array( true, $group_outcomes, true );
+ }
+
+ $action = $logic['action'] ?? 'show';
+
+ return 'hide' === $action ? ! $matched : $matched;
+ }
+
+ /**
+ * Resolve visibility for every field in a form at once.
+ *
+ * Runs to a fixed point so a hidden field's value reads as empty for everyone else: if the
+ * question was never asked, its answer must not satisfy another field's condition. On
+ * ambiguity — circular rules, or passes exhausted — the field is left visible, because a
+ * stray value in a response is recoverable and a silently discarded answer is not.
+ *
+ * @param array $fields Map of field id to `array( 'logic' => array|null, 'type' => string )`.
+ * @param array $form_values Map of field id to submitted value.
+ *
+ * @return array Map of field id to bool visibility.
+ */
+ public static function resolve_visibility( array $fields, array $form_values ): array {
+ $visible = array();
+ foreach ( $fields as $field_id => $descriptor ) {
+ $visible[ $field_id ] = true;
+ }
+
+ $with_logic = array();
+ $field_types = array();
+ $field_formats = array();
+ foreach ( $fields as $field_id => $descriptor ) {
+ $field_types[ $field_id ] = $descriptor['type'] ?? 'text';
+ if ( isset( $descriptor['format'] ) ) {
+ $field_formats[ $field_id ] = (string) $descriptor['format'];
+ }
+ if ( isset( $descriptor['logic'] ) && is_array( $descriptor['logic'] ) && ! empty( $descriptor['logic']['enabled'] ) ) {
+ $with_logic[] = $field_id;
+ }
+ }
+
+ if ( empty( $with_logic ) ) {
+ return $visible;
+ }
+
+ // An acyclic dependency chain settles at least one more level per pass, so one pass
+ // per conditional field, plus one to confirm nothing moved, always reaches the fixed
+ // point unless the rules are circular.
+ //
+ // This used to be clamped to a constant 25, which made that claim false: a chain
+ // deeper than 24 ran out of passes, was read as circular, and failed open with
+ // fields left visible that should have been hidden. The bound is the field count
+ // because that is what actually guarantees convergence -- and it is self-limiting,
+ // since it can only be as large as the form.
+ $max_passes = count( $with_logic ) + 1;
+
+ // Fields that change after the opening pass are reacting to another field's change,
+ // which is the signature of an oscillation. Collected across every pass, because a
+ // participant in a cycle need not be the one that flipped on the final pass.
+ $unstable = array();
+
+ for ( $pass = 0; $pass < $max_passes; $pass++ ) {
+ $effective = array();
+ foreach ( $fields as $field_id => $descriptor ) {
+ $value = array_key_exists( $field_id, $form_values ) ? $form_values[ $field_id ] : '';
+ $effective[ $field_id ] = $visible[ $field_id ] ? $value : '';
+ }
+
+ $changed_count = 0;
+ foreach ( $with_logic as $field_id ) {
+ $next = self::evaluate( $fields[ $field_id ]['logic'], $field_types, $effective, $field_formats );
+ if ( $next !== $visible[ $field_id ] ) {
+ $visible[ $field_id ] = $next;
+ ++$changed_count;
+ if ( $pass > 0 ) {
+ $unstable[ $field_id ] = true;
+ }
+ }
+ }
+
+ if ( 0 === $changed_count ) {
+ return $visible; // Fixed point.
+ }
+ }
+
+ // Passes exhausted, so the rules are circular. Fail open for everything in the cycle.
+ foreach ( array_keys( $unstable ) as $field_id ) {
+ $visible[ $field_id ] = true;
+ }
+
+ return $visible;
+ }
+
+ /**
+ * Evaluate a single rule against a submitted value.
+ *
+ * @param array $rule The rule.
+ * @param string $type_key Comparison behavior of the rule's subject field.
+ * @param mixed $actual The subject field's current value.
+ * @param string $format Subject field's date format: `mm/dd/yy`, `dd/mm/yy` or `yy-mm-dd`.
+ *
+ * @return bool|null True or false, or null when the rule must be ignored.
+ */
+ private static function evaluate_rule_value( array $rule, $type_key, $actual, $format = '' ) {
+ $operator = (string) $rule['operator'];
+ $needs_value = ! in_array( $operator, self::OPERATORS_WITHOUT_VALUE, true );
+ $expected = '';
+ if ( $needs_value && isset( $rule['value'] ) ) {
+ $expected = $rule['value'];
+ }
+
+ // An operator that compares against something, given nothing to compare against, cannot
+ // say anything -- so the rule is ignored rather than evaluated against an empty string.
+ // Evaluating it would be worse than useless: `does_not_contain ''` is true of every
+ // value, so a half-written rule would quietly force its field visible. The editor
+ // already tells the author this rule is inert; this is what makes that true.
+ if ( $needs_value && '' === trim( self::to_comparable_string( $expected ) ) ) {
+ return null;
+ }
+
+ switch ( $operator ) {
+ case self::OP_IS_EMPTY:
+ return self::is_empty_value( $actual );
+ case self::OP_IS_NOT_EMPTY:
+ return ! self::is_empty_value( $actual );
+ case self::OP_IS_CHECKED:
+ return ! self::is_empty_value( $actual );
+ case self::OP_IS_NOT_CHECKED:
+ return self::is_empty_value( $actual );
+ }
+
+ if ( 'multichoice' === $type_key ) {
+ $selection = self::to_selection_list( $actual );
+ $target = trim( self::to_comparable_string( $expected ) );
+
+ switch ( $operator ) {
+ case self::OP_CONTAINS:
+ return in_array( $target, $selection, true );
+ case self::OP_DOES_NOT_CONTAIN:
+ return ! in_array( $target, $selection, true );
+ }
+
+ return null;
+ }
+
+ if ( 'number' === $type_key || 'rating' === $type_key ) {
+ // A rating submits `selected/max`, e.g. `4/5`. The rule stores the bare number, so
+ // only the submitted side needs unpacking -- without it is_numeric() sees `4/5`,
+ // every comparison returns false, and a rating rule can never match.
+ $submitted = 'rating' === $type_key ? self::to_rating_value( $actual ) : $actual;
+ $pair = self::to_numeric_pair( $submitted, $expected );
+ if ( null === $pair ) {
+ return false;
+ }
+
+ switch ( $operator ) {
+ case self::OP_EQUALS:
+ return $pair[0] === $pair[1];
+ case self::OP_NOT_EQUALS:
+ return $pair[0] !== $pair[1];
+ case self::OP_GREATER_THAN:
+ return $pair[0] > $pair[1];
+ case self::OP_LESS_THAN:
+ return $pair[0] < $pair[1];
+ case self::OP_GTE:
+ return $pair[0] >= $pair[1];
+ case self::OP_LTE:
+ return $pair[0] <= $pair[1];
+ }
+
+ return null;
+ }
+
+ if ( 'date' === $type_key || 'time' === $type_key ) {
+ $pair = self::to_temporal_pair( $actual, $expected, $type_key, $format );
+ if ( null === $pair ) {
+ return false;
+ }
+
+ switch ( $operator ) {
+ case self::OP_IS:
+ return $pair[0] === $pair[1];
+ case self::OP_IS_NOT:
+ return $pair[0] !== $pair[1];
+ case self::OP_BEFORE:
+ return $pair[0] < $pair[1];
+ case self::OP_AFTER:
+ return $pair[0] > $pair[1];
+ }
+
+ return null;
+ }
+
+ // string, choice, hidden and file all compare textually.
+ $left = self::to_comparable_string( $actual );
+ $right = self::to_comparable_string( $expected );
+
+ switch ( $operator ) {
+ case self::OP_IS:
+ return $left === $right;
+ case self::OP_IS_NOT:
+ return $left !== $right;
+ case self::OP_CONTAINS:
+ return '' !== $right && false !== strpos( $left, $right );
+ case self::OP_DOES_NOT_CONTAIN:
+ return '' === $right || false === strpos( $left, $right );
+ }
+
+ return null;
+ }
+
+ /**
+ * Normalize a multi-select value into a list of selected option strings.
+ *
+ * Membership comparison exists so `contains "Blue"` does not match an option named
+ * "Blueberry", and so an option containing a comma cannot corrupt the comparison.
+ *
+ * @param mixed $value The submitted value.
+ *
+ * @return array Selected options, trimmed, with blanks removed.
+ */
+ private static function to_selection_list( $value ): array {
+ $raw = is_array( $value ) ? $value : array( $value );
+ $list = array();
+
+ foreach ( $raw as $item ) {
+ $text = trim( self::to_comparable_string( $item ) );
+ if ( '' !== $text ) {
+ $list[] = $text;
+ }
+ }
+
+ return $list;
+ }
+
+ /**
+ * The selected value of a rating, dropping the scale it was submitted with.
+ *
+ * @param mixed $value The submitted value, `selected/max` or a bare number.
+ *
+ * @return string The selected part, for numeric comparison.
+ */
+ private static function to_rating_value( $value ) {
+ $text = trim( self::to_comparable_string( $value ) );
+ $slash = strpos( $text, '/' );
+
+ return false === $slash ? $text : substr( $text, 0, $slash );
+ }
+
+ /**
+ * Parse both sides of a numeric comparison.
+ *
+ * @param mixed $actual The submitted value.
+ * @param mixed $expected The value configured on the rule.
+ *
+ * @return array|null Both sides as floats, or null when either side is not numeric.
+ */
+ private static function to_numeric_pair( $actual, $expected ) {
+ $left = trim( self::to_comparable_string( $actual ) );
+ $right = trim( self::to_comparable_string( $expected ) );
+
+ if ( '' === $left || '' === $right || ! is_numeric( $left ) || ! is_numeric( $right ) ) {
+ return null;
+ }
+
+ return array( (float) $left, (float) $right );
+ }
+
+ /**
+ * Parse both sides of a date or time comparison into comparable numbers.
+ *
+ * Times become minutes since midnight, so a bare `HH:MM` needs no date context.
+ *
+ * @param mixed $actual The submitted value.
+ * @param mixed $expected The value configured on the rule.
+ * @param string $type_key Either `date` or `time`.
+ * @param string $format Subject field's date format: `mm/dd/yy`, `dd/mm/yy` or `yy-mm-dd`.
+ *
+ * @return array|null Both sides as ints, or null when either side cannot be parsed.
+ */
+ private static function to_temporal_pair( $actual, $expected, $type_key, $format = '' ) {
+ // The submitted value is written in the field's format; a rule's value is always ISO.
+ $left = self::parse_temporal( $actual, $type_key, $format );
+ $right = self::parse_temporal( $expected, $type_key, '' );
+
+ if ( null === $left || null === $right ) {
+ return null;
+ }
+
+ return array( $left, $right );
+ }
+
+ /**
+ * Parse one side of a temporal comparison.
+ *
+ * @param mixed $value The value to parse.
+ * @param string $type_key Either `date` or `time`.
+ * @param string $format Subject field's date format: `mm/dd/yy`, `dd/mm/yy` or `yy-mm-dd`.
+ *
+ * @return int|null Comparable integer, or null when unparseable.
+ */
+ private static function parse_temporal( $value, $type_key, $format = '' ) {
+ $text = trim( self::to_comparable_string( $value ) );
+ if ( '' === $text ) {
+ return null;
+ }
+
+ if ( 'time' === $type_key ) {
+ if ( ! preg_match( '/^(\d{1,2}):(\d{2})/', $text, $matches ) ) {
+ return null;
+ }
+ return ( (int) $matches[1] ) * 60 + (int) $matches[2];
+ }
+
+ return self::parse_date( $text, $format );
+ }
+
+ /**
+ * Parse a date into a comparable YYYYMMDD integer.
+ *
+ * Deliberately not strtotime(). The browser has to reach the same answer, and its
+ * Date.parse() reads a bare `YYYY-MM-DD` as UTC while reading `mm/dd/yy` as local time --
+ * so for any visitor away from UTC the two engines disagreed by the offset, and `is` was
+ * false on one side while `after` was true on the other. A `dd/mm/yy` field was worse:
+ * neither engine could read `31/12/2026` at all, so a show-rule hid its field permanently
+ * and the answer was then dropped at storage.
+ *
+ * The field's own format decides how to read the value, the same way the datepicker
+ * writes it. A rule's value always arrives as ISO, since the rule builder uses a native
+ * date input, so ISO is accepted regardless of the field's format.
+ *
+ * @param string $text Date text.
+ * @param string $format Field date format: `mm/dd/yy`, `dd/mm/yy` or `yy-mm-dd`.
+ *
+ * @return int|null YYYYMMDD, or null when the text does not match.
+ */
+ private static function parse_date( $text, $format = '' ) {
+ // ISO first: the rule side is always ISO, and it is the default field format.
+ if ( preg_match( '/^(\d{4})-(\d{1,2})-(\d{1,2})$/', $text, $m ) ) {
+ return self::to_date_int( (int) $m[1], (int) $m[2], (int) $m[3] );
+ }
+
+ if ( ! preg_match( '/^(\d{1,4})[\/.-](\d{1,2})[\/.-](\d{1,4})$/', $text, $m ) ) {
+ return null;
+ }
+
+ // jQuery UI tokens, as used by the field: `yy` is the four-digit year.
+ switch ( $format ) {
+ case 'dd/mm/yy':
+ return self::to_date_int( (int) $m[3], (int) $m[2], (int) $m[1] );
+ case 'mm/dd/yy':
+ return self::to_date_int( (int) $m[3], (int) $m[1], (int) $m[2] );
+ default:
+ return self::to_date_int( (int) $m[1], (int) $m[2], (int) $m[3] );
+ }
+ }
+
+ /**
+ * Combine date parts, rejecting anything out of range.
+ *
+ * @param int $year Four-digit year.
+ * @param int $month Month.
+ * @param int $day Day.
+ *
+ * @return int|null YYYYMMDD, or null when the parts cannot be a date.
+ */
+ private static function to_date_int( $year, $month, $day ) {
+ if ( $month < 1 || $month > 12 || $day < 1 || $day > 31 ) {
+ return null;
+ }
+
+ return $year * 10000 + $month * 100 + $day;
+ }
+
+ /**
+ * Reduce any submitted value to a comparable string.
+ *
+ * @param mixed $value The submitted value.
+ *
+ * @return string Comparable string; empty for values with no sensible text form.
+ */
+ private static function to_comparable_string( $value ): string {
+ if ( null === $value ) {
+ return '';
+ }
+ if ( is_bool( $value ) ) {
+ return $value ? '1' : '';
+ }
+ if ( is_array( $value ) ) {
+ $parts = array();
+ foreach ( $value as $item ) {
+ $parts[] = self::to_comparable_string( $item );
+ }
+ return implode( ',', $parts );
+ }
+ if ( is_object( $value ) ) {
+ return '';
+ }
+
+ return (string) $value;
+ }
+
+ /**
+ * Whether a submitted value counts as unanswered.
+ *
+ * @param mixed $value The submitted value.
+ *
+ * @return bool True when the field has no answer.
+ */
+ private static function is_empty_value( $value ): bool {
+ if ( null === $value ) {
+ return true;
+ }
+ if ( is_string( $value ) ) {
+ return '' === trim( $value );
+ }
+ if ( is_bool( $value ) ) {
+ return ! $value;
+ }
+ if ( is_array( $value ) ) {
+ foreach ( $value as $item ) {
+ if ( ! self::is_empty_value( $item ) ) {
+ return false;
+ }
+ }
+ return true;
+ }
+ if ( is_object( $value ) ) {
+ foreach ( get_object_vars( $value ) as $item ) {
+ if ( ! self::is_empty_value( $item ) ) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/projects/packages/forms/src/contact-form/class-contact-form-field.php b/projects/packages/forms/src/contact-form/class-contact-form-field.php
index 12f85124ef1c..a07128a107fb 100644
--- a/projects/packages/forms/src/contact-form/class-contact-form-field.php
+++ b/projects/packages/forms/src/contact-form/class-contact-form-field.php
@@ -125,6 +125,20 @@ class Contact_Form_Field extends Contact_Form_Shortcode {
*/
public $label_styles = '';
+ /**
+ * Input tuple used for the cached hidden-field filter result.
+ *
+ * @var array|null
+ */
+ private $hidden_field_filter_input;
+
+ /**
+ * Cached hidden-field filter result.
+ *
+ * @var mixed
+ */
+ private $hidden_field_filter_value;
+
/**
* Constructor function.
*
@@ -203,11 +217,20 @@ public function __construct( $attributes, $content = null, $form = null ) {
'showotheroption' => null,
// derived from block metadata for blockVisibility support
'labelhiddenbyblockvisibility' => null,
+ // JSON-encoded conditional logic config; decoded below.
+ 'conditionallogic' => null,
),
$attributes,
'contact-field'
);
+ if ( ! empty( $attributes['conditionallogic'] ) && is_string( $attributes['conditionallogic'] ) ) {
+ $decoded = json_decode( html_entity_decode( $attributes['conditionallogic'], ENT_COMPAT ), true );
+ $attributes['conditionallogic'] = is_array( $decoded ) ? $decoded : null;
+ } elseif ( ! is_array( $attributes['conditionallogic'] ) ) {
+ $attributes['conditionallogic'] = null;
+ }
+
// special default for subject field
if ( 'subject' === $attributes['type'] && $attributes['default'] === null && $form !== null ) {
$attributes['default'] = $form->get_attribute( 'subject' );
@@ -336,6 +359,17 @@ public function has_value() {
return ! empty( trim( $field_value ) );
}
+ /**
+ * Whether this field's visibility is governed by conditional logic.
+ *
+ * @return bool True when the field carries an enabled conditional-logic config.
+ */
+ public function has_conditional_logic() {
+ $logic = $this->get_attribute( 'conditionallogic' );
+
+ return is_array( $logic ) && ! empty( $logic['enabled'] );
+ }
+
/**
* Validates the form input
*/
@@ -799,6 +833,26 @@ public function get_computed_field_value( $field_type, $field_id ) {
return sanitize_textarea_field( wp_unslash( $_POST[ $field_id ] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- no site changes.
}
+ // Explicit consent never renders checked, so a missing POST value must remain empty
+ // rather than falling through to a query-string or configured default.
+ if ( 'consent' === $field_type && 'explicit' === $this->get_attribute( 'consenttype' ) ) {
+ return '';
+ }
+
+ // Checkbox controls are omitted from the request when unchecked. During an actual
+ // submission that absence is the submitted value; falling through to a query-string
+ // or configured default would silently check the field again.
+ $is_checkbox_control = in_array( $field_type, array( 'checkbox', 'checkbox-multiple' ), true );
+ if ( $is_checkbox_control && $this->form && $this->form->is_current_submission() ) {
+ return '';
+ }
+
+ // Implicit consent renders as a hidden input with this value, so its computed value
+ // must match what the browser will submit from the first render onward.
+ if ( 'consent' === $field_type && 'explicit' !== $this->get_attribute( 'consenttype' ) ) {
+ return __( 'Yes', 'jetpack-forms' );
+ }
+
// Use the GET Field if it is available.
if ( isset( $_GET[ $field_id ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- no site changes.
if ( is_array( $_GET[ $field_id ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- no site changes.
@@ -838,6 +892,74 @@ public function get_computed_field_value( $field_type, $field_id ) {
return $this->get_attribute( 'default' );
}
+ /**
+ * Get the field value used to resolve conditional logic.
+ *
+ * Hidden-field filters affect the value rendered into the browser, so apply the same
+ * filter before the server evaluates a rule that uses a hidden field as its subject.
+ *
+ * @return string|array Field value.
+ */
+ public function get_conditional_logic_value() {
+ $field_type = $this->get_attribute( 'type' );
+ $field_id = $this->get_attribute( 'id' );
+ $value = $this->get_computed_field_value( $field_type, $field_id );
+
+ if ( 'hidden' === $field_type && ! $this->is_submitted_hidden_field_value( $field_id ) ) {
+ $value = $this->get_filtered_hidden_field_value( $value, $this->get_attribute( 'label' ), $field_id );
+ }
+
+ return $value;
+ }
+
+ /**
+ * Filter the value of a hidden field once per input tuple.
+ *
+ * Rendering and conditional logic must use the exact same filtered value. Caching also
+ * prevents stateful filters from returning a different value on their second invocation.
+ *
+ * @param mixed $value The value of the hidden field.
+ * @param string $label The label of the hidden field.
+ * @param string $id The ID of the hidden field.
+ * @return mixed The modified value of the hidden field.
+ */
+ private function get_filtered_hidden_field_value( $value, $label, $id ) {
+ $input = array( $value, $label, $id );
+
+ if ( $input === $this->hidden_field_filter_input ) {
+ return $this->hidden_field_filter_value;
+ }
+
+ /**
+ * Filter the value of the hidden field.
+ *
+ * @since 6.3.0
+ *
+ * @param string $value The value of the hidden field.
+ * @param string $label The label of the hidden field.
+ * @param string $id The ID of the hidden field.
+ */
+ $this->hidden_field_filter_input = $input;
+ $this->hidden_field_filter_value = apply_filters( 'jetpack_forms_hidden_field_value', $value, $label, $id );
+
+ return $this->hidden_field_filter_value;
+ }
+
+ /**
+ * Whether a hidden value came from a matching form submission.
+ *
+ * Hidden values rendered into the browser have already passed through the hidden-field
+ * filter, so their submitted values must not be filtered a second time.
+ *
+ * @param string $id The ID of the hidden field.
+ * @return bool Whether the submitted value should be treated as already filtered.
+ */
+ private function is_submitted_hidden_field_value( $id ) {
+ return isset( $_POST[ $id ] ) // phpcs:ignore WordPress.Security.NonceVerification.Missing -- no site changes.
+ && $this->form
+ && $this->form->is_current_submission();
+ }
+
/**
* Return the HTML for the label.
*
@@ -1986,20 +2108,26 @@ private static function get_file_field_allowed_html() {
* @return string HTML for the hidden field.
*/
private function render_hidden_field( $id, $label, $value ) {
- /**
- *
- * Filter the value of the hidden field.
- *
- * @since 6.3.0
- *
- * @param string $value The value of the hidden field.
- * @param string $label The label of the hidden field.
- * @param string $id The ID of the hidden field.
- *
- * @return string The modified value of the hidden field.
- */
- $value = apply_filters( 'jetpack_forms_hidden_field_value', $value, $label, $id );
- return "\n";
+ if ( ! $this->is_submitted_hidden_field_value( $id ) ) {
+ $value = $this->get_filtered_hidden_field_value( $value, $label, $id );
+ }
+
+ $context = array(
+ 'fieldId' => $id,
+ 'fieldType' => 'hidden',
+ 'fieldLabel' => $label,
+ 'fieldValue' => $value,
+ 'fieldIsRequired' => false,
+ 'fieldExtra' => array(),
+ 'formHash' => $this->form ? $this->form->hash : '',
+ );
+
+ $interactivity_attributes = "data-jp-field-id='" . esc_attr( $id ) . "' data-wp-interactive='jetpack/form' "
+ . wp_interactivity_data_wp_context( $context )
+ . " data-wp-init='callbacks.initializeField' data-wp-on--jetpack-form-reset='callbacks.initializeField'";
+
+ return "\n";
}
/**
@@ -2927,8 +3055,26 @@ public function render_field( $type, $id, $label, $value, $class, $placeholder,
'formHash' => $this->form->hash,
);
+ // Conditional logic is not emitted per field: it cascades, so resolving it needs every
+ // field's logic and type at once. The form block emits that map once as
+ // `conditionalLogic`, and this field's wrapper reads its own entry from there.
$interactivity_attrs = ' data-wp-interactive="jetpack/form" ' . wp_interactivity_data_wp_context( $context ) . ' ';
+ // Hiding a field has to hide whichever element occupies the slot in the row, or the
+ // field disappears and leaves a hole behind it. For an inset label that is the outer
+ // wrapper, which is where the width class lives -- the inner div is inside it and
+ // carries no width of its own.
+ // data-jp-visibility-root names the element the initial server-side render stamps, so
+ // the first paint and the runtime always hide the same element. Matching on
+ // data-jp-field-id instead would stamp the inner div even when the wrapper is the one
+ // the runtime hides.
+ $visibility_attrs = " data-jp-visibility-root='" . esc_attr( $id ) . "'"
+ . ( $this->has_conditional_logic() ? " data-jp-conditional='1'" : '' )
+ . ' data-wp-class--jetpack-field--conditionally-hidden="state.isFieldHidden"'
+ // Runs whenever the field's visibility changes, so focus does not fall to
+ // when the field the visitor is filling in disappears under them.
+ . ' data-wp-watch--conditional-focus="callbacks.manageConditionalFocus"';
+
// Fields with an inset label need an extra wrapper to show the error message below the input.
if ( $has_inset_label ) {
$field_width = $this->get_attribute( 'width' );
@@ -2938,11 +3084,12 @@ public function render_field( $type, $id, $label, $value, $class, $placeholder,
array_push( $inset_label_class, 'grunion-field-width-' . $field_width . '-wrap' );
}
- $field .= "\n
\n";
+ $field .= "\n
\n";
$interactivity_attrs = ''; // Reset interactivity attributes for the field wrapper.
+ $visibility_attrs = ''; // The outer wrapper owns visibility for this layout.
}
- $field .= "\n
\n"; // new in Jetpack 6.8.0
+ $field .= "\n
\n"; // new in Jetpack 6.8.0
switch ( $type ) {
case 'email':
diff --git a/projects/packages/forms/src/contact-form/class-contact-form-plugin.php b/projects/packages/forms/src/contact-form/class-contact-form-plugin.php
index 2dca2c20ac8b..9829242a9533 100644
--- a/projects/packages/forms/src/contact-form/class-contact-form-plugin.php
+++ b/projects/packages/forms/src/contact-form/class-contact-form-plugin.php
@@ -569,6 +569,33 @@ public static function block_attributes_to_shortcode_attributes( $atts, $type, $
unset( $atts['defaultValue'] );
}
+ // Serialize the conditionalLogic object so it survives the shortcode roundtrip.
+ // Only emit when explicitly enabled to keep the shortcode and frontend context lean.
+ //
+ // JSON_HEX_TAG matters as much as JSON_HEX_AMP here: this lands in `post_content` as
+ // shortcode text and is decoded back in Contact_Form_Field, and KSES rewrites a bare
+ // `<` on the way in. A rule comparing against a value containing `<` would come back
+ // as unparseable JSON and silently drop the field's whole condition.
+ if ( isset( $atts['conditionalLogic'] ) ) {
+ $logic = $atts['conditionalLogic'];
+ if ( is_array( $logic ) && ! empty( $logic['enabled'] ) ) {
+ $json = \wp_json_encode( $logic, JSON_UNESCAPED_SLASHES | JSON_HEX_AMP | JSON_HEX_TAG );
+
+ // The rules are a JSON array, so the value contains `[` and `]`. WordPress's
+ // shortcode attribute pattern excludes both, so as shortcode text the value is
+ // cut short and the attribute is dropped entirely -- leaving a field that is
+ // still required but no longer conditional, which blocks submission on a
+ // question the visitor cannot see. Numeric entities survive the pattern and
+ // are turned back by the html_entity_decode() in Contact_Form_Field.
+ $atts['conditionallogic'] = str_replace(
+ array( '[', ']' ),
+ array( '[', ']' ),
+ (string) $json
+ );
+ }
+ unset( $atts['conditionalLogic'] );
+ }
+
// Process inner blocks to shortcode attributes.
if ( $block && ! empty( $block->parsed_block['innerBlocks'] ) ) {
// Only apply the block style classes to the field wrapper if the field is one of the new inner block types.
@@ -1849,6 +1876,17 @@ public function process_form_submission() {
return Form_Submission_Error::system_error( 'form_not_found', __( 'Form not found.', 'jetpack-forms' ) );
}
+ // Conditional fields cannot be validated while the form is still being parsed: a rule's
+ // subject may not exist yet, so `parse_contact_field()` defers them. Something has to
+ // validate them once the whole form is known, and on this path nothing did -- the JWT
+ // branch calls `validate()` above, but this one went straight to `has_errors()`. A
+ // required conditional field left empty, an invalid email or an out-of-allow-list choice
+ // would all be stored unchecked.
+ //
+ // Fields that were validated at parse time early-return once `is_error()` is set, so
+ // this is idempotent for everything else.
+ $form->validate();
+
if ( $form->has_errors() ) {
return $form->errors;
}
diff --git a/projects/packages/forms/src/contact-form/class-contact-form.php b/projects/packages/forms/src/contact-form/class-contact-form.php
index 1dc50fa85c11..823ec0ceffcc 100644
--- a/projects/packages/forms/src/contact-form/class-contact-form.php
+++ b/projects/packages/forms/src/contact-form/class-contact-form.php
@@ -9,6 +9,7 @@
use Automattic\Jetpack\Connection\Tokens;
use Automattic\Jetpack\Forms\Dashboard\Dashboard as Forms_Dashboard;
+use Automattic\Jetpack\Forms\Jetpack_Forms;
use Automattic\Jetpack\JWT;
use Automattic\Jetpack\Sync\Settings;
use PHPMailer\PHPMailer\PHPMailer;
@@ -155,6 +156,15 @@ class Contact_Form extends Contact_Form_Shortcode {
*/
private $source;
+ /**
+ * Cached map of field id to conditional-logic visibility for this submission.
+ *
+ * Null until resolved. Shared by validation and storage so the two cannot disagree.
+ *
+ * @var array|null
+ */
+ private $resolved_field_visibility = null;
+
/**
* The reference ID for the contact form.
*
@@ -587,6 +597,90 @@ public function __construct( $attributes, $content = null, $set_id = true ) {
// $this->body and $this->fields have been setup. We no longer need the contact-field shortcode.
Contact_Form_Plugin::$using_contact_form_field = false;
+
+ $this->apply_initial_field_visibility();
+ }
+
+ /**
+ * Whether the current request is submitting this form.
+ *
+ * Normal submissions are identified by their action, ID, and hash. JWT submissions omit
+ * the action, but the plugin validates their token before constructing and validating the
+ * form, so the matching ID and hash identify the submitted form here.
+ *
+ * @return bool
+ */
+ public function is_current_submission() {
+ if ( ! isset( $_POST['contact-form-id'] ) || ! isset( $_POST['contact-form-hash'] ) || ! is_string( $_POST['contact-form-hash'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- no site changes.
+ return false;
+ }
+
+ $form_id = sanitize_text_field( wp_unslash( $_POST['contact-form-id'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- no site changes.
+ $form_hash = sanitize_text_field( wp_unslash( $_POST['contact-form-hash'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- no site changes.
+
+ if ( (string) $this->get_attribute( 'id' ) !== $form_id || ! hash_equals( $this->hash, $form_hash ) ) {
+ return false;
+ }
+
+ if ( isset( $_POST['jetpack_contact_form_jwt'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- JWT validation happens before form validation.
+ return true;
+ }
+
+ return isset( $_POST['action'] ) // phpcs:ignore WordPress.Security.NonceVerification.Missing -- no site changes.
+ && 'grunion-contact-form' === sanitize_text_field( wp_unslash( $_POST['action'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- no site changes.
+ }
+
+ /**
+ * Mark conditionally hidden fields as hidden in the rendered markup.
+ *
+ * Without this the server sends every field visible and the browser hides them once the
+ * interactivity store hydrates, so the visitor sees the hidden fields flash on screen
+ * first. The class applied here is the same one the client toggles, so the first paint
+ * already matches what the client will compute and nothing moves.
+ *
+ * This runs after the whole form is parsed. It cannot happen while fields render: they
+ * are parsed one at a time, so a rule referring to a field further down the form would be
+ * resolved against a form that does not exist yet.
+ *
+ * Public so it can be exercised directly; it is idempotent and safe to call again.
+ *
+ * @return void
+ */
+ public function apply_initial_field_visibility() {
+ if ( empty( $this->body ) || ! Jetpack_Forms::is_conditional_logic_enabled() ) {
+ return;
+ }
+
+ // Deliberately not get_resolved_field_visibility(): that caches for the submission, and
+ // this runs while the form is still being built. Seeding the cache here would hand a
+ // render-time answer to validation and storage later on.
+ $visibility = $this->compute_field_visibility();
+ $hidden = array();
+
+ foreach ( $visibility as $field_id => $is_visible ) {
+ if ( false === $is_visible ) {
+ $hidden[ $field_id ] = true;
+ }
+ }
+
+ if ( empty( $hidden ) ) {
+ return;
+ }
+
+ $processor = new \WP_HTML_Tag_Processor( $this->body );
+
+ // Matches the element the runtime hides, which is not always the one carrying
+ // data-jp-field-id: an inset label puts the width class on an outer wrapper, and
+ // hiding the inner div there leaves the wrapper holding its slot in the row.
+ while ( $processor->next_tag( array( 'tag_name' => 'DIV' ) ) ) {
+ $field_id = $processor->get_attribute( 'data-jp-visibility-root' );
+
+ if ( null !== $field_id && isset( $hidden[ $field_id ] ) ) {
+ $processor->add_class( 'jetpack-field--conditionally-hidden' );
+ }
+ }
+
+ $this->body = $processor->get_updated_html();
}
/**
* Get the instance of the contact form from a JWT token.
@@ -1521,6 +1615,7 @@ public static function parse( $attributes, $content, $context = array() ) {
'elementId' => $element_id,
'isSingleInputForm' => $is_single_input_form,
'isForcedHorizontal' => $is_forced_horizontal,
+ 'conditionalLogic' => $form->get_conditional_logic_context(),
);
if ( $is_multistep ) {
@@ -2592,15 +2687,26 @@ public static function parse_contact_field( $attributes, $content, $block = null
if ( // phpcs:disable WordPress.Security.NonceVerification.Missing
! isset( $_POST['jetpack_contact_form_jwt'] )
- &&
- isset( $_POST['action'] ) && 'grunion-contact-form' === $_POST['action']
- &&
- isset( $_POST['contact-form-id'] ) && (string) $form->get_attribute( 'id' ) === $_POST['contact-form-id']
- &&
- isset( $_POST['contact-form-hash'] ) && is_string( $_POST['contact-form-hash'] ) && hash_equals( $form->hash, wp_unslash( $_POST['contact-form-hash'] ) )
+ && $form->is_current_submission()
) { // phpcs:enable
// If we're processing a POST submission for this contact form, validate the field value so we can show errors as necessary.
- $field->validate();
+ //
+ // A field carrying conditional logic is skipped here. Whether it is visible depends
+ // on the answers to other fields, and fields are appended to $form->fields as they
+ // parse — at this point the form is still incomplete, so the question cannot be
+ // answered correctly. Validating anyway records an error against a field the visitor
+ // may never see, which leaves the form permanently unsubmittable: the error is real
+ // to has_errors(), but invisible on screen and impossible to clear.
+ //
+ // Contact_Form::validate() re-validates every field once the form is fully parsed,
+ // and skips the ones conditional logic resolves as hidden, so nothing is lost by
+ // deferring: a visible field still gets its error, just a moment later.
+ $defer_to_full_form_validation = Jetpack_Forms::is_conditional_logic_enabled()
+ && $field->has_conditional_logic();
+
+ if ( ! $defer_to_full_form_validation ) {
+ $field->validate();
+ }
}
// Output HTML
@@ -3107,6 +3213,17 @@ public function process_submission() {
update_post_meta( $post_id, '_feedback_akismet_values', $this->addslashes_deep( $akismet_values ) );
}
+ // Integrations must not see a field the visitor was never shown. MailPoet in
+ // particular reads this payload directly for explicit consent and the subscriber's
+ // email, so a forged POST naming a hidden consent field could otherwise subscribe
+ // someone off a question that was never on screen.
+ $visible_fields = $this->fields;
+ foreach ( $this->get_resolved_field_visibility() as $field_id => $is_visible ) {
+ if ( false === $is_visible ) {
+ unset( $visible_fields[ $field_id ] );
+ }
+ }
+
/**
* Fires after the feedback post for the contact form submission has been inserted.
*
@@ -3115,11 +3232,12 @@ public function process_submission() {
* @since 8.6.0
*
* @param integer $post_id The post id that contains the contact form data.
- * @param array $this->fields An array containg the form's Contact_Form_Field objects.
+ * @param array $visible_fields The form's Contact_Form_Field objects, less any that
+ * conditional logic hid from the visitor.
* @param boolean $is_spam Whether the form submission has been identified as spam.
* @param array $entry_values The feedback entry values.
*/
- do_action( 'grunion_after_feedback_post_inserted', $post_id, $this->fields, $is_spam, $entry_values );
+ do_action( 'grunion_after_feedback_post_inserted', $post_id, $visible_fields, $is_spam, $entry_values );
// Build the complete email content via the renderer.
$context_data = array(
@@ -3776,8 +3894,17 @@ public static function add_theme_json_data_for_classic_themes( $theme_json_data
*/
public function validate() {
$has_value = false;
+ // A field hidden by conditional logic was never shown to the visitor, so validating it
+ // would block submission on an error they cannot see or clear — most visibly when the
+ // hidden field is also required.
+ $visibility = $this->get_resolved_field_visibility();
+
// Validate the form fields before processing the form.
- foreach ( $this->fields as $field ) {
+ foreach ( $this->fields as $field_id => $field ) {
+ if ( isset( $visibility[ $field_id ] ) && false === $visibility[ $field_id ] ) {
+ continue;
+ }
+
$field->validate();
if ( ! $has_value && $field->has_value() ) {
$has_value = true;
@@ -3794,6 +3921,118 @@ public function validate() {
}
}
+ /**
+ * Build the form-level conditional-logic context handed to the front end.
+ *
+ * Two maps rather than one: `types` covers every field, because any of them may be the
+ * subject of a rule, while `logic` covers only the few that carry conditions. Emitting
+ * types solely for fields that have logic would leave the evaluator unable to resolve the
+ * subject of most rules, and it ignores rules whose subject it cannot type.
+ *
+ * Returns an empty array when no field uses conditional logic, so the common case adds
+ * nothing to the page.
+ *
+ * @return array Either an empty array or `array( 'types' => ..., 'logic' => ... )`.
+ */
+ public function get_conditional_logic_context() {
+ if ( ! Jetpack_Forms::is_conditional_logic_enabled() ) {
+ return array();
+ }
+
+ $types = array();
+ $logic = array();
+ $formats = array();
+
+ foreach ( $this->fields as $field_id => $field ) {
+ $types[ $field_id ] = $field->get_attribute( 'type' );
+
+ $date_format = $field->get_attribute( 'dateformat' );
+ if ( ! empty( $date_format ) ) {
+ $formats[ $field_id ] = $date_format;
+ }
+
+ $field_logic = $field->get_attribute( 'conditionallogic' );
+ if ( is_array( $field_logic ) && ! empty( $field_logic['enabled'] ) ) {
+ $logic[ $field_id ] = $field_logic;
+ }
+ }
+
+ if ( empty( $logic ) ) {
+ return array();
+ }
+
+ return array(
+ 'types' => $types,
+ 'logic' => $logic,
+ // Only date fields appear here; everything else compares without a format.
+ 'formats' => $formats,
+ );
+ }
+
+ /**
+ * Resolve which fields are visible for the current submission.
+ *
+ * Computed once and cached: validation and storage both consult it, and letting them
+ * resolve separately would risk them disagreeing about whether a field was shown.
+ *
+ * @return array Map of field id to bool visibility.
+ */
+ public function get_resolved_field_visibility() {
+ if ( null !== $this->resolved_field_visibility ) {
+ return $this->resolved_field_visibility;
+ }
+
+ // With the feature off every field is visible, so validation and storage behave
+ // exactly as they did before conditional logic existed. This is the single choke
+ // point for the runtime: callers do not need their own flag checks.
+ if ( ! Jetpack_Forms::is_conditional_logic_enabled() ) {
+ $this->resolved_field_visibility = array();
+
+ return $this->resolved_field_visibility;
+ }
+
+ $this->resolved_field_visibility = $this->compute_field_visibility();
+
+ return $this->resolved_field_visibility;
+ }
+
+ /**
+ * Resolve which fields are visible, without caching.
+ *
+ * @return array Map of field id to bool visibility.
+ */
+ private function compute_field_visibility() {
+ if ( ! Jetpack_Forms::is_conditional_logic_enabled() ) {
+ return array();
+ }
+
+ if ( ! is_array( $this->fields ) || empty( $this->fields ) ) {
+ return array();
+ }
+
+ $descriptors = array();
+ $values = array();
+
+ foreach ( $this->fields as $field_id => $field ) {
+ $descriptors[ $field_id ] = array(
+ 'logic' => $field->get_attribute( 'conditionallogic' ),
+ 'type' => $field->get_attribute( 'type' ),
+ // A date field's value is written in its own format, and the comparison has
+ // to read it the same way the datepicker wrote it.
+ 'format' => $field->get_attribute( 'dateformat' ),
+ );
+
+ // Resolve the value exactly as the field itself does when rendering: submitted
+ // value first, then a `?field_id=value` query parameter, then the configured
+ // default, then the logged-in user's details. Reading $_POST alone would make a
+ // prefilled form resolve against an empty one, so a field the visitor can already
+ // see satisfying a condition would render hidden and then flash into view.
+ $values[ $field_id ] = $field->get_conditional_logic_value();
+ }
+
+ return Conditional_Logic::resolve_visibility( $descriptors, $values );
+ }
+
/**
* Validate the form reference.
*
diff --git a/projects/packages/forms/src/contact-form/class-feedback.php b/projects/packages/forms/src/contact-form/class-feedback.php
index ab455cb5bb12..08bc9df0fcb6 100644
--- a/projects/packages/forms/src/contact-form/class-feedback.php
+++ b/projects/packages/forms/src/contact-form/class-feedback.php
@@ -325,6 +325,13 @@ public static function maybe_backfill_source_meta( $post_id, $feedback ) {
*/
protected $has_consent = false;
+ /**
+ * Whether this response was loaded from structured feedback data.
+ *
+ * @var bool
+ */
+ protected $uses_structured_fields = false;
+
/**
* Whether the feedback entry is unread.
*
@@ -507,6 +514,15 @@ public function set_source( $source ) {
*/
private function load_from_submission( $post_data, $form, $current_post = null, $current_page_number = 1 ) {
+ // Drop the answers to fields conditional logic hid, once, before anything reads them.
+ //
+ // get_computed_fields() already skips hidden fields for the stored response, but the
+ // comment content, the consent flag, the author details and the notification
+ // recipients all read $post_data directly and were still seeing them. Stripping here
+ // is what makes "a hidden field was never answered" true for every consumer instead
+ // of just the one.
+ $post_data = self::without_hidden_answers( $post_data, $form );
+
$this->source = Feedback_Source::from_submission( $current_post, $current_page_number );
// Use the form's ref attribute as the authoritative form ID.
@@ -543,6 +559,32 @@ private function load_from_submission( $post_data, $form, $current_post = null,
}
}
+ /**
+ * Remove submitted values belonging to fields conditional logic resolved as hidden.
+ *
+ * The form owns the resolution and caches it, so this asks rather than resolving again --
+ * a second resolution over a different value source is exactly what let validation and
+ * storage disagree about a prefilled consent field.
+ *
+ * @param array $post_data The post data from the form submission.
+ * @param Contact_Form $form The form object.
+ * @return array The post data, less any hidden field's answer.
+ */
+ private static function without_hidden_answers( $post_data, $form ) {
+ if ( ! is_array( $post_data ) ) {
+ return $post_data;
+ }
+
+ // Empty when the feature is off, so this is a no-op then.
+ foreach ( $form->get_resolved_field_visibility() as $field_id => $is_visible ) {
+ if ( false === $is_visible ) {
+ unset( $post_data[ $field_id ] );
+ }
+ }
+
+ return $post_data;
+ }
+
/**
* Get a sanitized value from the post data.
*
@@ -775,6 +817,15 @@ public function has_field_type( $type ) {
return false;
}
+ /**
+ * Whether this response uses structured feedback fields.
+ *
+ * @return bool
+ */
+ public function uses_structured_fields() {
+ return $this->uses_structured_fields;
+ }
+
/**
* Get the values related to where the form was submitted from.
*
@@ -1654,9 +1705,11 @@ public function serialize() {
*/
private function parse_content( $post_content = '', $version = null ) {
if ( $version === 'v3' ) {
+ $this->uses_structured_fields = true;
return $this->parse_content_v3( $post_content );
}
if ( $version === 'v2' ) {
+ $this->uses_structured_fields = true;
return $this->parse_content_v2( $post_content );
}
@@ -1671,6 +1724,7 @@ private function parse_content( $post_content = '', $version = null ) {
$decoded_content = json_decode( stripslashes( trim( $post_content ) ), true );
}
if ( isset( $decoded_content['fields'] ) && is_array( $decoded_content['fields'] ) ) {
+ $this->uses_structured_fields = true;
return $this->parse_content_v3( $post_content );
}
}
@@ -2202,16 +2256,54 @@ private function get_computed_fields( $post_data, $form ) {
$fields = array();
$field_ids = $form->get_field_ids();
- // For all fields, grab label and value
- $i = 1;
+
+ // Collect renderable fields and their submitted values up front so conditional logic
+ // rules (which may reference any sibling field) can be evaluated in the loop below.
+ $renderable = array();
+ $form_values = array();
foreach ( $field_ids['all'] as $field_id ) {
$field = $form->fields[ $field_id ];
$type = $field->get_attribute( 'type' );
if ( ! $field->is_field_renderable( $type ) ) {
continue;
}
+ $value = $this->get_field_value( $field_id, $post_data, $type );
+ $form_values[ $field_id ] = $value;
+ $renderable[ $field_id ] = array(
+ 'field' => $field,
+ 'type' => $type,
+ 'value' => $value,
+ );
+ }
+
+ // Ask the form, rather than resolving a second time.
+ //
+ // Storage used to run its own resolve_visibility() over a different value source and a
+ // different field set than validation did, and the two disagreed. Validation reads
+ // get_computed_field_value() -- POST, then GET, then the field's default, then the
+ // logged-in user -- while this loop reads POST only; and it skips anything
+ // is_field_renderable() rejects, so a rule whose subject is an option-less select was
+ // evaluated during validation and ignored here.
+ //
+ // Unchecking a consent field prefilled from a query argument hit both: the browser
+ // posts nothing, validation fell back to the query argument and read it checked,
+ // storage read '' and read it unchecked. The dependent field was required-validated
+ // and then had its answer dropped -- the silently discarded answer this feature is
+ // supposed to make impossible.
+ //
+ // Returns an empty array when the flag is off, so there is nothing extra to guard.
+ $visibility = $form->get_resolved_field_visibility();
+
+ $i = 1;
+ foreach ( $renderable as $field_id => $entry ) {
+ $field = $entry['field'];
+ $type = $entry['type'];
+ $value = $entry['value'];
+
+ if ( isset( $visibility[ $field_id ] ) && false === $visibility[ $field_id ] ) {
+ continue;
+ }
- $value = $this->get_field_value( $field_id, $post_data, $type );
$label = wp_strip_all_tags( $field->get_attribute( 'label' ) );
$key = $i . '_' . $label;
@@ -2227,7 +2319,7 @@ private function get_computed_fields( $post_data, $form ) {
if ( ! $this->has_file && $fields[ $key ]->has_file() ) {
$this->has_file = true;
}
- ++$i; // Increment prefix counter for the next field.
+ ++$i;
}
return $fields;
diff --git a/projects/packages/forms/src/contact-form/css/grunion.scss b/projects/packages/forms/src/contact-form/css/grunion.scss
index f60389b93a02..710065b839e0 100644
--- a/projects/packages/forms/src/contact-form/css/grunion.scss
+++ b/projects/packages/forms/src/contact-form/css/grunion.scss
@@ -1664,3 +1664,45 @@ on production builds, the attributes are being reordered, causing side-effects
display: block;
}
}
+
+/* Conditional logic hides the entire field wrapper and its inputs. */
+.jetpack-field--conditionally-hidden {
+ display: none !important;
+}
+
+/*
+ * Ease a conditional field in and out rather than
+ * snapping it.
+ *
+ * Scoped to [data-jp-conditional] so only fields that
+ * carry a condition transition; every other field
+ * renders with no animation cost. `display` is in the
+ * transition with allow-discrete so the element still
+ * leaves the layout when the exit finishes, and
+ * @starting-style supplies the entry state for an
+ * element arriving from display: none. Browsers without
+ * those features show and hide instantly.
+ *
+ * None of this runs when the visitor has asked for
+ * reduced motion: the rule above is the whole
+ * behaviour in that case.
+ */
+@media not (prefers-reduced-motion) {
+
+ [data-jp-conditional] {
+ transition:
+ opacity 120ms ease-out,
+ transform 120ms ease-out,
+ display 120ms allow-discrete;
+
+ @starting-style {
+ opacity: 0;
+ transform: translateY(-4px);
+ }
+ }
+
+ .jetpack-field--conditionally-hidden[data-jp-conditional] {
+ opacity: 0;
+ transform: translateY(-4px);
+ }
+}
diff --git a/projects/packages/forms/src/modules/form/conditional-visibility.js b/projects/packages/forms/src/modules/form/conditional-visibility.js
new file mode 100644
index 000000000000..759c48af4c4f
--- /dev/null
+++ b/projects/packages/forms/src/modules/form/conditional-visibility.js
@@ -0,0 +1,96 @@
+import { resolveVisibility } from '../../blocks/shared/conditional-logic/util/evaluate.ts';
+
+/**
+ * Resolved visibility, memoized per form.
+ *
+ * Keyed by form hash rather than held in a single slot: a page can carry more than one form,
+ * and two forms often produce the same value signature — field ids are derived from labels,
+ * so a "Name"/"Other" pair repeats readily. A shared slot would hand one form's visibility
+ * map to another and show or hide the wrong fields.
+ *
+ * @type {Map}
+ */
+const memoByForm = new Map();
+
+/**
+ * Discard memoized visibility. Exposed for tests.
+ *
+ * @return {void}
+ */
+export const clearVisibilityMemo = () => {
+ memoByForm.clear();
+};
+
+/**
+ * Resolve visibility for every field in the form the current field belongs to.
+ *
+ * The form block emits `conditionalLogic` as `{ types, logic }`: a type for every field, so
+ * rules can reference any of them, plus logic for only the fields that have some. When no
+ * field uses conditional logic the key is absent and this returns null, so callers can skip
+ * the work entirely.
+ *
+ * `isFieldHidden` is read once per field but conditional logic cascades across the whole
+ * form, so the resolver considers every field on each call; the memo keeps a keystroke at one
+ * resolution rather than one per field.
+ *
+ * @param {object} context - The interactivity context, merged from form and field level.
+ * @return {object|null} Map of field id to visibility, or null when nothing is conditional.
+ */
+export const resolveFormVisibility = context => {
+ const conditionalLogic = context?.conditionalLogic;
+ const logicByField = conditionalLogic?.logic;
+
+ if ( ! logicByField || ! Object.keys( logicByField ).length ) {
+ return null;
+ }
+
+ const typesByField = conditionalLogic.types || {};
+ // Only date fields carry one; the comparison needs it to read the value the way the
+ // datepicker wrote it.
+ const formatsByField = conditionalLogic.formats || {};
+ const fields = context.fields || {};
+ const values = {};
+ for ( const id in fields ) {
+ values[ id ] = fields[ id ]?.value;
+ }
+
+ const formKey = context.formHash || 'default';
+ const signature = JSON.stringify( values );
+ const cached = memoByForm.get( formKey );
+
+ if ( cached && cached.signature === signature ) {
+ return cached.map;
+ }
+
+ const descriptors = {};
+ for ( const id in typesByField ) {
+ descriptors[ id ] = {
+ logic: logicByField[ id ] || null,
+ type: typesByField[ id ],
+ format: formatsByField[ id ],
+ };
+ }
+
+ const map = resolveVisibility( descriptors, values );
+ memoByForm.set( formKey, { signature, map } );
+
+ return map;
+};
+
+/**
+ * Whether a field is currently hidden by conditional logic.
+ *
+ * Validation has to agree with what is on screen. A required field the visitor cannot see is
+ * one they cannot fill, so counting its error would block the form with nothing to explain
+ * why — the submit button simply stops working. The server drops the same fields before
+ * validating, so skipping them here keeps the two sides in step.
+ *
+ * @param {object} context - The interactivity context.
+ * @param {string} fieldId - The field's id.
+ * @return {boolean} True when conditional logic hides the field.
+ */
+export const isFieldHiddenByLogic = ( context, fieldId ) => {
+ const visibility = resolveFormVisibility( context );
+
+ return !! visibility && false === visibility[ fieldId ];
+};
diff --git a/projects/packages/forms/src/modules/form/view.js b/projects/packages/forms/src/modules/form/view.js
index c1594c09015a..1e21af6df83c 100644
--- a/projects/packages/forms/src/modules/form/view.js
+++ b/projects/packages/forms/src/modules/form/view.js
@@ -13,6 +13,7 @@ import {
*/
import { validateField, isEmptyValue } from '../../contact-form/js/validate-helper.js';
import { getRating } from '../field-rating/view.js';
+import { isFieldHiddenByLogic } from './conditional-visibility.js';
import { maybeAddColonToLabel, maybeTransformValue, getImages, getUrl } from './helpers.js';
import { focusNextInput, getForm, submitForm } from './shared.ts';
// Import field type icons view to register its callbacks.
@@ -24,6 +25,33 @@ const withSyncEvent =
( ...args ) =>
cb( ...args ) );
+/**
+ * Re-read field values from the DOM after a back/forward restore.
+ *
+ * The interactivity context is only written by the input and change handlers, and neither
+ * fires when the browser restores a page from the bfcache -- it puts the visitor's values
+ * straight back into the DOM. Conditional logic then keeps resolving against the values the
+ * context still holds, so a dependent field can stay hidden even though the restored trigger
+ * should reveal it, with no way out short of re-touching the trigger. Autofill has the same
+ * shape across some engines.
+ *
+ * Dispatching `input` routes through the same handler the visitor's typing does, so there is
+ * no second code path to keep in step.
+ */
+if ( typeof window !== 'undefined' ) {
+ window.addEventListener( 'pageshow', event => {
+ if ( ! event.persisted ) {
+ return;
+ }
+
+ document
+ .querySelectorAll(
+ '[data-jp-field-id] input, [data-jp-field-id] select, [data-jp-field-id] textarea'
+ )
+ .forEach( element => element.dispatchEvent( new Event( 'input', { bubbles: true } ) ) );
+ } );
+}
+
const NAMESPACE = 'jetpack/form';
const config = getConfig( NAMESPACE );
let errorTimeout = null;
@@ -312,7 +340,9 @@ const { state, actions } = store( NAMESPACE, {
return false;
}
- return ! Object.values( context.fields ).some( field => ! isEmptyValue( field.value ) );
+ return ! Object.values( context.fields ).some(
+ field => ! isEmptyValue( field.value ) && ! isFieldHiddenByLogic( context, field.id )
+ );
},
get isStepActive() {
@@ -382,13 +412,21 @@ const { state, actions } = store( NAMESPACE, {
return false;
}
const context = getContext();
+ // A field hidden by conditional logic is skipped: the visitor cannot see it, so an
+ // error against it would block submission with nothing on screen to explain why.
+ // The server re-checks visibility and drops the same fields before validating.
if ( context.isMultiStep ) {
// For multistep forms, we only validate fields that are part of the current step.
return ! Object.values( context.fields ).some(
- field => field.error !== 'yes' && field.step === context.currentStep
+ field =>
+ field.error !== 'yes' &&
+ field.step === context.currentStep &&
+ ! isFieldHiddenByLogic( context, field.id )
);
}
- return ! Object.values( context.fields ).some( field => field.error !== 'yes' );
+ return ! Object.values( context.fields ).some(
+ field => field.error !== 'yes' && ! isFieldHiddenByLogic( context, field.id )
+ );
},
get showFormErrors() {
@@ -433,6 +471,9 @@ const { state, actions } = store( NAMESPACE, {
if ( context.isMultiStep && field.step !== context.currentStep ) {
return;
}
+ if ( isFieldHiddenByLogic( context, field.id ) ) {
+ return;
+ }
if ( field.error && field.error !== 'yes' ) {
errors.push( {
anchor: '#' + field.id,
@@ -451,6 +492,14 @@ const { state, actions } = store( NAMESPACE, {
const field = context.fields[ fieldId ];
return field?.value || '';
},
+
+ get isFieldHidden() {
+ // The shared helper rather than a second copy of the same three lines: a fix to
+ // how hiding is decided has to reach the class binding too.
+ const context = getContext();
+
+ return isFieldHiddenByLogic( context, context.fieldId );
+ },
},
actions: {
@@ -486,7 +535,14 @@ const { state, actions } = store( NAMESPACE, {
const fieldId = context.fieldId;
const field = context.fields[ fieldId ];
- if ( context.fieldType === 'checkbox' ) {
+ // Keyed off what the control actually is, not what the field is called. A consent
+ // field renders as a checkbox but carries fieldType 'consent', so it skipped this
+ // and stored its literal `Yes` value whether checked or not. Conditional logic
+ // reads consent as a boolean, so unchecking it still read as checked: the fields
+ // it was supposed to gate stayed on screen and got filled in, and the server then
+ // resolved them hidden and dropped the answers. Anything checkbox-rendered later
+ // is covered by the same test.
+ if ( event.target.type === 'checkbox' ) {
value = event.target.checked ? '1' : '';
}
@@ -805,6 +861,43 @@ const { state, actions } = store( NAMESPACE, {
},
callbacks: {
+ /**
+ * Keep focus somewhere usable when conditional logic hides the focused field.
+ *
+ * `display: none` is right for tab order and the accessibility tree, but a keyboard or
+ * screen-reader user filling this field can have it disappear because their answer to
+ * another field cascaded. Focus then falls to with nothing to explain it.
+ * scrollToWrapper() already moves focus for a store-driven DOM change; this matches.
+ */
+ manageConditionalFocus() {
+ const context = getContext();
+ const { ref } = getElement();
+
+ if ( ! ref || ! isFieldHiddenByLogic( context, context.fieldId ) ) {
+ return;
+ }
+
+ // ownerDocument rather than the global: the form may be inside an iframe, as it is
+ // in the editor preview.
+ const activeElement = ref.ownerDocument?.activeElement;
+
+ if ( ! activeElement || ! ref.contains( activeElement ) ) {
+ return;
+ }
+
+ // The nearest still-visible control, so the visitor carries on where they were
+ // rather than being sent to the top of the form.
+ const form = ref.closest( 'form' );
+ const candidates = form
+ ? Array.from( form.querySelectorAll( 'input, select, textarea, button' ) )
+ : [];
+ const next = candidates.find(
+ element => ! ref.contains( element ) && null !== element.offsetParent
+ );
+
+ ( next || form )?.focus?.();
+ },
+
initializeField() {
const context = getContext();
const { fieldId, fieldType, fieldLabel, fieldValue, fieldIsRequired, fieldExtra } = context;
diff --git a/projects/packages/forms/src/service/class-hostinger-reach-integration.php b/projects/packages/forms/src/service/class-hostinger-reach-integration.php
index 773e2003cea5..bdc22ea7f47f 100644
--- a/projects/packages/forms/src/service/class-hostinger-reach-integration.php
+++ b/projects/packages/forms/src/service/class-hostinger-reach-integration.php
@@ -120,21 +120,9 @@ protected static function get_subscriber_data( $feedback ) {
* @return array Associative array with at least 'email', optionally 'first_name', 'last_name'. Empty array if no email found.
*/
protected static function get_subscriber_data_from_fields( $fields ) {
- // Try and get the form from any of the fields
- $form = null;
- foreach ( $fields as $field ) {
- if ( ! empty( $field->form ) ) {
- $form = $field->form;
- break;
- }
- }
- if ( ! $form || ! is_a( $form, 'Automattic\\Jetpack\\Forms\\ContactForm\\Contact_Form' ) ) {
- return array();
- }
-
$subscriber_data = array();
- foreach ( $form->fields as $field ) {
+ foreach ( $fields as $field ) {
$id = strtolower( str_replace( array( ' ', '_' ), '', $field->get_attribute( 'id' ) ) );
$label = strtolower( str_replace( array( ' ', '_' ), '', $field->get_attribute( 'label' ) ) );
@@ -164,12 +152,12 @@ protected static function get_subscriber_data_from_fields( $fields ) {
* Check if submission has consent when required.
*
* @param Feedback $feedback Feedback object for the submission.
- * @param mixed $form Contact form instance.
- * @param bool $is_v2 Whether the data comes from v2 storage.
+ * @param array $fields Visible submitted fields.
+ * @param bool $uses_feedback_api Whether the data uses structured feedback storage.
* @return bool True if consent is present or not required; false otherwise.
*/
- protected static function has_consent( $feedback, $form, $is_v2 ) {
- if ( $is_v2 ) {
+ protected static function has_consent( $feedback, $fields, $uses_feedback_api ) {
+ if ( $uses_feedback_api ) {
if ( $feedback->has_field_type( 'consent' ) && ! $feedback->has_consent() ) {
return false;
}
@@ -177,8 +165,8 @@ protected static function has_consent( $feedback, $form, $is_v2 ) {
}
$consent_field = null;
- if ( is_object( $form ) && is_array( $form->fields ) ) {
- foreach ( $form->fields as $form_field ) {
+ if ( is_array( $fields ) ) {
+ foreach ( $fields as $form_field ) {
if ( 'consent' === $form_field->get_attribute( 'type' ) ) {
$consent_field = $form_field;
break;
@@ -237,13 +225,12 @@ public static function handle_hostinger_reach_integration( $post_id, $fields, $i
}
// Respect consent if a consent field exists.
- $post = get_post( $post_id );
- $is_v2_data = ( $post && $post->post_mime_type === 'v2' );
- if ( ! self::has_consent( $feedback, $form, $is_v2_data ) ) {
+ $uses_feedback_api = $feedback->uses_structured_fields();
+ if ( ! self::has_consent( $feedback, $fields, $uses_feedback_api ) ) {
return;
}
- $subscriber_data = $is_v2_data ? self::get_subscriber_data( $feedback ) : self::get_subscriber_data_from_fields( $fields );
+ $subscriber_data = $uses_feedback_api ? self::get_subscriber_data( $feedback ) : self::get_subscriber_data_from_fields( $fields );
if ( empty( $subscriber_data ) ) {
return;
}
diff --git a/projects/packages/forms/src/service/class-mailpoet-integration.php b/projects/packages/forms/src/service/class-mailpoet-integration.php
index 4200edd63829..5b1f2b378edf 100644
--- a/projects/packages/forms/src/service/class-mailpoet-integration.php
+++ b/projects/packages/forms/src/service/class-mailpoet-integration.php
@@ -161,20 +161,8 @@ protected static function add_subscriber_to_list( $mailpoet_api, $list_id, $subs
* @return array Associative array with at least 'email', optionally 'first_name', 'last_name'. Empty array if no email found.
*/
protected static function get_subscriber_data_from_fields( $fields ) {
- // Try and get the form from any of the fields
- $form = null;
- foreach ( $fields as $field ) {
- if ( ! empty( $field->form ) ) {
- $form = $field->form;
- break;
- }
- }
- if ( ! $form || ! is_a( $form, 'Automattic\Jetpack\Forms\ContactForm\Contact_Form' ) ) {
- return array();
- }
-
$subscriber_data = array();
- foreach ( $form->fields as $field ) {
+ foreach ( $fields as $field ) {
$type = strtolower( (string) $field->get_attribute( 'type' ) );
$id = strtolower( str_replace( array( ' ', '_' ), '', (string) $field->get_attribute( 'id' ) ) );
$label = strtolower( str_replace( array( ' ', '_' ), '', (string) $field->get_attribute( 'label' ) ) );
@@ -269,17 +257,16 @@ public static function handle_mailpoet_integration( $post_id, $fields, $is_spam
return;
}
- $post = get_post( $post_id );
- $is_v2_data = ( $post && $post->post_mime_type === 'v2' );
+ $uses_feedback_api = $feedback->uses_structured_fields();
- if ( $is_v2_data ) {
+ if ( $uses_feedback_api ) {
if ( $feedback->has_field_type( 'consent' ) && ! $feedback->has_consent() ) {
return;
}
} else {
$consent_field = null;
- if ( is_array( $form->fields ) ) {
- foreach ( $form->fields as $form_field ) {
+ if ( is_array( $fields ) ) {
+ foreach ( $fields as $form_field ) {
if ( 'consent' === $form_field->get_attribute( 'type' ) ) {
$consent_field = $form_field;
break;
@@ -311,7 +298,7 @@ public static function handle_mailpoet_integration( $post_id, $fields, $is_spam
return;
}
- $subscriber_data = $is_v2_data ? self::get_subscriber_data( $feedback ) : self::get_subscriber_data_from_fields( $fields );
+ $subscriber_data = $uses_feedback_api ? self::get_subscriber_data( $feedback ) : self::get_subscriber_data_from_fields( $fields );
if ( empty( $subscriber_data ) ) {
// Email is required for MailPoet subscribers.
return;
diff --git a/projects/packages/forms/src/service/class-post-to-url.php b/projects/packages/forms/src/service/class-post-to-url.php
index e5babe9ff7a1..474a94df563b 100644
--- a/projects/packages/forms/src/service/class-post-to-url.php
+++ b/projects/packages/forms/src/service/class-post-to-url.php
@@ -104,7 +104,7 @@ public function feedback_post_hook( $post_id, $fields, $is_spam, $entry_values )
return;
}
- $form_data = $this->get_form_data( $form, $entry_values );
+ $form_data = $this->get_form_data( $form, $fields, $entry_values );
$result = $this->post_to_url( $form_data, $setup );
@@ -152,11 +152,12 @@ private function post_to_url( $data, $options = array() ) {
* Sanitizes the hidden fields values
*
* @param \Automattic\Jetpack\Forms\ContactForm\Contact_Form $form The form instance being processed/submitted.
+ * @param array $visible_fields Visible submitted fields.
* @param array $entry_values The feedback entry values.
*/
- private function get_form_data( $form, $entry_values ) {
+ private function get_form_data( $form, $visible_fields, $entry_values ) {
$fields = array();
- foreach ( $form->fields as $field ) {
+ foreach ( $visible_fields as $field ) {
$fields[ $field->get_attribute( 'id' ) ] = $field->value;
}
diff --git a/projects/packages/forms/tests/fixtures/conditional-logic-behaviour.json b/projects/packages/forms/tests/fixtures/conditional-logic-behaviour.json
new file mode 100644
index 000000000000..6be2dcc3a484
--- /dev/null
+++ b/projects/packages/forms/tests/fixtures/conditional-logic-behaviour.json
@@ -0,0 +1,292 @@
+{
+ "_comment": [
+ "Behavioural parity between the JS and PHP conditional-logic evaluators.",
+ "",
+ "The other parity test pins vocabulary: the operator names and the field type table.",
+ "That is worth having, but it is not where the two implementations actually drift --",
+ "both the Date.parse/strtotime disagreement and the consent value-shape bug passed it.",
+ "Drift lives in the comparisons, so this table pins those instead.",
+ "",
+ "Each case builds a one-rule 'show' condition on a single subject field and asserts the",
+ "field's resulting visibility. Consumed by evaluate.test.js and",
+ "Conditional_Logic_Behaviour_Test.php, which must agree case for case.",
+ "",
+ "Fields: type is the shortcode field type, actual is the submitted value, value is the",
+ "rule's operand, format is the field's dateformat where it has one."
+ ],
+ "cases": [
+ {
+ "name": "iso date equal to itself",
+ "type": "date",
+ "format": "yy-mm-dd",
+ "operator": "is",
+ "actual": "2026-03-15",
+ "value": "2026-03-15",
+ "visible": true
+ },
+ {
+ "name": "us format compared against the rule's iso value",
+ "why": "Date.parse read the rule value as UTC and the field value as local, so this was false in the browser and true on the server for anyone away from UTC.",
+ "type": "date",
+ "format": "mm/dd/yy",
+ "operator": "is",
+ "actual": "03/15/2026",
+ "value": "2026-03-15",
+ "visible": true
+ },
+ {
+ "name": "us format after",
+ "type": "date",
+ "format": "mm/dd/yy",
+ "operator": "after",
+ "actual": "03/16/2026",
+ "value": "2026-03-15",
+ "visible": true
+ },
+ {
+ "name": "day-first format, day past the twelfth",
+ "why": "31/12/2026 parsed on neither side, so a show rule hid its field permanently.",
+ "type": "date",
+ "format": "dd/mm/yy",
+ "operator": "is",
+ "actual": "31/12/2026",
+ "value": "2026-12-31",
+ "visible": true
+ },
+ {
+ "name": "day-first format is not read as month-first",
+ "why": "05/06/2026 parsed as May 6 under both engines regardless of the field's format.",
+ "type": "date",
+ "format": "dd/mm/yy",
+ "operator": "is",
+ "actual": "05/06/2026",
+ "value": "2026-06-05",
+ "visible": true
+ },
+ {
+ "name": "day-first format before",
+ "type": "date",
+ "format": "dd/mm/yy",
+ "operator": "before",
+ "actual": "01/12/2026",
+ "value": "2026-12-31",
+ "visible": true
+ },
+ {
+ "name": "unparseable date fails the rule",
+ "type": "date",
+ "format": "yy-mm-dd",
+ "operator": "is",
+ "actual": "not a date",
+ "value": "2026-03-15",
+ "visible": false
+ },
+ {
+ "name": "impossible month fails the rule",
+ "type": "date",
+ "format": "mm/dd/yy",
+ "operator": "is",
+ "actual": "13/01/2026",
+ "value": "2026-01-13",
+ "visible": false
+ },
+ {
+ "name": "time equal",
+ "type": "time",
+ "operator": "is",
+ "actual": "09:30",
+ "value": "09:30",
+ "visible": true
+ },
+ {
+ "name": "time after",
+ "type": "time",
+ "operator": "after",
+ "actual": "10:00",
+ "value": "09:30",
+ "visible": true
+ },
+ {
+ "name": "time before is not after",
+ "type": "time",
+ "operator": "after",
+ "actual": "08:00",
+ "value": "09:30",
+ "visible": false
+ },
+ {
+ "name": "number equality ignores trailing zeros",
+ "why": "is_numeric versus Number() is the other place the two engines can disagree.",
+ "type": "number",
+ "operator": "equals",
+ "actual": "10",
+ "value": "10.0",
+ "visible": true
+ },
+ {
+ "name": "number greater than",
+ "type": "number",
+ "operator": "greater_than",
+ "actual": "11",
+ "value": "10",
+ "visible": true
+ },
+ {
+ "name": "number comparison against an empty value fails",
+ "type": "number",
+ "operator": "equals",
+ "actual": "",
+ "value": "0",
+ "visible": false
+ },
+ {
+ "name": "negative number less than",
+ "type": "number",
+ "operator": "less_than",
+ "actual": "-5",
+ "value": "0",
+ "visible": true
+ },
+ {
+ "name": "a checked consent field reads as checked",
+ "type": "consent",
+ "operator": "is_checked",
+ "actual": "1",
+ "value": "",
+ "visible": true
+ },
+ {
+ "name": "an unchecked consent field does not read as checked",
+ "why": "The browser stored the literal 'Yes' on uncheck, so this read true on screen and false on the server.",
+ "type": "consent",
+ "operator": "is_checked",
+ "actual": "",
+ "value": "",
+ "visible": false
+ },
+ {
+ "name": "an unchecked checkbox reads as not checked",
+ "type": "checkbox",
+ "operator": "is_not_checked",
+ "actual": "",
+ "value": "",
+ "visible": true
+ },
+ {
+ "name": "text contains is case sensitive",
+ "type": "text",
+ "operator": "contains",
+ "actual": "Hello World",
+ "value": "World",
+ "visible": true
+ },
+ {
+ "name": "text contains does not match a different case",
+ "type": "text",
+ "operator": "contains",
+ "actual": "Hello World",
+ "value": "world",
+ "visible": false
+ },
+ {
+ "name": "text is empty",
+ "type": "text",
+ "operator": "is_empty",
+ "actual": "",
+ "value": "",
+ "visible": true
+ },
+ {
+ "name": "text is not empty",
+ "type": "text",
+ "operator": "is_not_empty",
+ "actual": "something",
+ "value": "",
+ "visible": true
+ },
+ {
+ "name": "select is",
+ "type": "select",
+ "operator": "is",
+ "actual": "Large",
+ "value": "Large",
+ "visible": true
+ },
+ {
+ "name": "select is not",
+ "type": "select",
+ "operator": "is_not",
+ "actual": "Small",
+ "value": "Large",
+ "visible": true
+ },
+ {
+ "name": "rating equals its selected value",
+ "why": "A rating submits \"selected/max\", e.g. 4/5, not a bare number.",
+ "type": "rating",
+ "operator": "equals",
+ "actual": "4/5",
+ "value": "4",
+ "visible": true
+ },
+ {
+ "name": "rating greater than",
+ "type": "rating",
+ "operator": "greater_than",
+ "actual": "4/5",
+ "value": "3",
+ "visible": true
+ },
+ {
+ "name": "rating not greater than",
+ "type": "rating",
+ "operator": "greater_than",
+ "actual": "2/5",
+ "value": "3",
+ "visible": false
+ },
+ {
+ "name": "an unrated field is empty",
+ "type": "rating",
+ "operator": "is_empty",
+ "actual": "",
+ "value": "",
+ "visible": true
+ },
+ {
+ "name": "an unfinished rule does not force the field visible",
+ "why": "does_not_contain against an empty value is true of every value, so evaluating a half-written rule instead of skipping it quietly forced its field visible. The editor marks such a rule inert; this is what makes that true.",
+ "type": "text",
+ "operator": "does_not_contain",
+ "actual": "anything",
+ "value": "",
+ "visible": true
+ },
+ {
+ "name": "an unfinished is-rule does not match a blank subject",
+ "why": "`is` against an empty value matched whenever the subject happened to be blank, so the rule fired by accident rather than being skipped.",
+ "type": "text",
+ "operator": "is",
+ "actual": "",
+ "value": "",
+ "visible": true
+ },
+ {
+ "name": "an unfinished numeric rule is skipped",
+ "type": "number",
+ "operator": "greater_than",
+ "actual": "10",
+ "value": "",
+ "visible": true
+ },
+ {
+ "name": "a zero value is a real value, not a missing one",
+ "why": "Zero is falsy in both languages, so a rule comparing against 0 must not be mistaken for one with no value.",
+ "type": "number",
+ "operator": "equals",
+ "actual": "0",
+ "value": "0",
+ "visible": true
+ }
+ ]
+}
diff --git a/projects/packages/forms/tests/js/blocks/shared/conditional-logic/block-names.test.js b/projects/packages/forms/tests/js/blocks/shared/conditional-logic/block-names.test.js
new file mode 100644
index 000000000000..5923662a9775
--- /dev/null
+++ b/projects/packages/forms/tests/js/blocks/shared/conditional-logic/block-names.test.js
@@ -0,0 +1,105 @@
+import fs from 'fs';
+import path from 'path';
+import { getOperatorsForTypeKey } from '../../../../../src/blocks/shared/conditional-logic/util/field-types';
+
+/**
+ * Every field block declares how its value is compared, beside `form_editor`.
+ *
+ * This reads the block sources rather than restating the block list, for two reasons. A new
+ * field block that forgets the declaration is reported here instead of silently getting no
+ * conditional-logic support. And the registered name is taken from the block itself: two
+ * blocks register under a name that differs from their directory — `field-single-choice` as
+ * `jetpack/field-radio`, `field-multiple-choice` as `jetpack/field-checkbox-multiple` — and a
+ * hand-written table got both wrong, which is how both silently lost their panel once.
+ */
+const BLOCKS_DIR = path.join( process.cwd(), 'src/blocks' );
+
+const readFieldBlocks = () =>
+ fs
+ .readdirSync( BLOCKS_DIR )
+ .filter( entry => entry.startsWith( 'field-' ) )
+ .map( dir => {
+ const indexFile = fs
+ .readdirSync( path.join( BLOCKS_DIR, dir ) )
+ .find( file => /^index\.(js|jsx|ts|tsx)$/.test( file ) );
+
+ if ( ! indexFile ) {
+ return null;
+ }
+
+ const source = fs.readFileSync( path.join( BLOCKS_DIR, dir, indexFile ), 'utf8' );
+ const name = source.match( /^(?:export )?const name = '([^']+)'/m );
+ const type = source.match( /export const conditional_logic = \{\s*type: '([a-z]+)'/m );
+
+ return {
+ dir,
+ blockName: name ? `jetpack/${ name[ 1 ] }` : null,
+ type: type ? type[ 1 ] : null,
+ // Declared but not exported would leave it invisible to the lookup.
+ exported: /export default \{[^}]*conditional_logic/s.test( source ),
+ };
+ } )
+ .filter( Boolean );
+
+// The expected comparison behavior of each field block, keyed by registered block name.
+const EXPECTED_TYPES = {
+ 'jetpack/field-text': 'string',
+ 'jetpack/field-name': 'string',
+ 'jetpack/field-email': 'string',
+ 'jetpack/field-url': 'string',
+ 'jetpack/field-textarea': 'string',
+ 'jetpack/field-telephone': 'string',
+ 'jetpack/field-select': 'choice',
+ 'jetpack/field-radio': 'choice',
+ 'jetpack/field-checkbox-multiple': 'multichoice',
+ 'jetpack/field-number': 'number',
+ 'jetpack/field-slider': 'number',
+ 'jetpack/field-rating': 'rating',
+ 'jetpack/field-date': 'date',
+ 'jetpack/field-time': 'time',
+ 'jetpack/field-checkbox': 'boolean',
+ 'jetpack/field-consent': 'boolean',
+ 'jetpack/field-hidden': 'hidden',
+ 'jetpack/field-file': 'file',
+};
+
+describe( 'field block conditional-logic declarations', () => {
+ const blocks = readFieldBlocks();
+
+ it( 'finds every field block in the package', () => {
+ expect( blocks ).toHaveLength( 19 );
+ // One block deliberately opts out, see below.
+ expect( Object.keys( EXPECTED_TYPES ) ).toHaveLength( 18 );
+ } );
+
+ /**
+ * An image-select field submits a JSON document, not the label the rule builder offers,
+ * so every evaluator would compare the two and never match: `is` permanently false, `is
+ * not` permanently true. Offering no rule beats offering one that cannot fire.
+ */
+ it( 'leaves image-select without conditional-logic support', () => {
+ const imageSelect = blocks.find( block => block.dir === 'field-image-select' );
+
+ expect( imageSelect.type ).toBeNull();
+ expect( imageSelect.exported ).toBe( false );
+ } );
+
+ it( 'registers the block names the table expects', () => {
+ const declaring = blocks.filter( block => block.type !== null );
+
+ expect( declaring.map( block => block.blockName ).sort() ).toEqual(
+ Object.keys( EXPECTED_TYPES ).sort()
+ );
+ } );
+
+ it.each(
+ readFieldBlocks()
+ .filter( block => block.type !== null )
+ .map( block => [ block.dir, block ] )
+ )( '%s declares and exports its comparison behavior', ( dir, block ) => {
+ expect( block.type ).toBe( EXPECTED_TYPES[ block.blockName ] );
+ expect( block.exported ).toBe( true );
+ // The declared type must be one the rule builder can offer operators for.
+ expect( getOperatorsForTypeKey( block.type ).length ).toBeGreaterThan( 0 );
+ } );
+} );
diff --git a/projects/packages/forms/tests/js/blocks/shared/conditional-logic/constants.test.js b/projects/packages/forms/tests/js/blocks/shared/conditional-logic/constants.test.js
new file mode 100644
index 000000000000..497d3cfc67d3
--- /dev/null
+++ b/projects/packages/forms/tests/js/blocks/shared/conditional-logic/constants.test.js
@@ -0,0 +1,50 @@
+import {
+ countRules,
+ normalizeLogic,
+ startsHidden,
+} from '../../../../../src/blocks/shared/conditional-logic/constants.js';
+
+const withRules = ( action, count ) =>
+ normalizeLogic( {
+ enabled: count > 0,
+ action,
+ groups: count
+ ? [
+ {
+ logicalOperator: 'all',
+ rules: Array.from( { length: count }, () => ( {
+ field: 'a',
+ operator: 'is',
+ value: 'x',
+ } ) ),
+ },
+ ]
+ : [],
+ } );
+
+/**
+ * The toolbar icon and the builder's opening line both report this, so it lives in one place
+ * rather than being decided twice.
+ */
+describe( 'startsHidden', () => {
+ it( 'is true for a show rule, which reveals a field that begins hidden', () => {
+ expect( startsHidden( withRules( 'show', 1 ) ) ).toBe( true );
+ } );
+
+ it( 'is false for a hide rule, which removes a field that begins visible', () => {
+ expect( startsHidden( withRules( 'hide', 1 ) ) ).toBe( false );
+ } );
+
+ // The action defaults to `show`, so a field with no conditions would otherwise read as
+ // hidden -- when in fact nothing is conditional about it at all.
+ it( 'is false for a field with no conditions', () => {
+ expect( startsHidden( withRules( 'show', 0 ) ) ).toBe( false );
+ } );
+} );
+
+describe( 'countRules', () => {
+ it( 'counts across the groups', () => {
+ expect( countRules( withRules( 'show', 3 ) ) ).toBe( 3 );
+ expect( countRules( withRules( 'show', 0 ) ) ).toBe( 0 );
+ } );
+} );
diff --git a/projects/packages/forms/tests/js/blocks/shared/conditional-logic/evaluate.test.js b/projects/packages/forms/tests/js/blocks/shared/conditional-logic/evaluate.test.js
new file mode 100644
index 000000000000..b8d61af7dfd3
--- /dev/null
+++ b/projects/packages/forms/tests/js/blocks/shared/conditional-logic/evaluate.test.js
@@ -0,0 +1,518 @@
+import { readFileSync } from 'fs';
+import path from 'path';
+import {
+ evaluateLogic,
+ resolveVisibility,
+} from '../../../../../src/blocks/shared/conditional-logic/util/evaluate';
+
+/**
+ * One group holding every rule, which is what the V1 panel writes.
+ *
+ * `logicalOperator` in `extra` sets that group's operator, since with a single group the
+ * top-level one is inert -- it combines groups with each other.
+ *
+ * @param {Array} rules - Rules for the group.
+ * @param {object} extra - Top-level overrides; `logicalOperator` applies to the group.
+ * @return {object} A logic object.
+ */
+const logic = ( rules, extra = {} ) => {
+ const { logicalOperator = 'all', ...rest } = extra;
+
+ return {
+ enabled: true,
+ action: 'show',
+ logicalOperator: 'any',
+ groups: [ { logicalOperator, rules } ],
+ ...rest,
+ };
+};
+
+const one = ( operator, value, field = 'a' ) => logic( [ { field, operator, value } ] );
+
+describe( 'evaluateLogic — string operators', () => {
+ const types = { a: 'text' };
+
+ it.each( [
+ [ 'is', 'yes', 'yes', true ],
+ [ 'is', 'yes', 'no', false ],
+ [ 'is_not', 'yes', 'no', true ],
+ [ 'is_not', 'yes', 'yes', false ],
+ [ 'contains', 'blueberry', 'blue', true ],
+ [ 'contains', 'blueberry', 'red', false ],
+ [ 'does_not_contain', 'blueberry', 'red', true ],
+ [ 'does_not_contain', 'blueberry', 'blue', false ],
+ ] )( '%s: %p vs %p', ( operator, actual, expected, want ) => {
+ expect( evaluateLogic( one( operator, expected ), types, { a: actual } ) ).toBe( want );
+ } );
+
+ it.each( [
+ [ 'is_empty', '', true ],
+ [ 'is_empty', ' ', true ],
+ [ 'is_empty', 'x', false ],
+ [ 'is_not_empty', 'x', true ],
+ [ 'is_not_empty', '', false ],
+ ] )( '%s: %p', ( operator, actual, want ) => {
+ expect( evaluateLogic( one( operator, '' ), types, { a: actual } ) ).toBe( want );
+ } );
+} );
+
+describe( 'evaluateLogic — multiple choice uses membership', () => {
+ const types = { a: 'checkbox-multiple' };
+
+ it( 'does not match a longer option that merely contains the value', () => {
+ expect( evaluateLogic( one( 'contains', 'Blue' ), types, { a: [ 'Blueberry' ] } ) ).toBe(
+ false
+ );
+ } );
+
+ it( 'matches an exact selected option', () => {
+ expect( evaluateLogic( one( 'contains', 'Blue' ), types, { a: [ 'Blue', 'Red' ] } ) ).toBe(
+ true
+ );
+ } );
+
+ it( 'handles does_not_contain', () => {
+ expect( evaluateLogic( one( 'does_not_contain', 'Blue' ), types, { a: [ 'Red' ] } ) ).toBe(
+ true
+ );
+ expect( evaluateLogic( one( 'does_not_contain', 'Blue' ), types, { a: [ 'Blue' ] } ) ).toBe(
+ false
+ );
+ } );
+
+ it( 'is not fooled by a comma inside an option label', () => {
+ expect( evaluateLogic( one( 'contains', 'Yes' ), types, { a: [ 'Yes, please' ] } ) ).toBe(
+ false
+ );
+ expect(
+ evaluateLogic( one( 'contains', 'Yes, please' ), types, { a: [ 'Yes, please' ] } )
+ ).toBe( true );
+ } );
+
+ it( 'treats an empty selection as empty', () => {
+ expect( evaluateLogic( one( 'is_empty', '' ), types, { a: [] } ) ).toBe( true );
+ expect( evaluateLogic( one( 'is_not_empty', '' ), types, { a: [ 'Blue' ] } ) ).toBe( true );
+ } );
+
+ it( 'accepts a single string selection', () => {
+ expect( evaluateLogic( one( 'contains', 'Blue' ), types, { a: 'Blue' } ) ).toBe( true );
+ } );
+} );
+
+describe( 'evaluateLogic — choice operators', () => {
+ const types = { a: 'select' };
+
+ it( 'compares the whole selected option', () => {
+ expect( evaluateLogic( one( 'is', 'Blue' ), types, { a: 'Blue' } ) ).toBe( true );
+ expect( evaluateLogic( one( 'is', 'Blue' ), types, { a: 'Blueberry' } ) ).toBe( false );
+ expect( evaluateLogic( one( 'is_not', 'Blue' ), types, { a: 'Red' } ) ).toBe( true );
+ } );
+} );
+
+describe( 'evaluateLogic — numeric operators', () => {
+ const types = { a: 'number' };
+
+ it.each( [
+ [ 'equals', '10.0', '10', true ],
+ [ 'equals', '11', '10', false ],
+ [ 'not_equals', '11', '10', true ],
+ [ 'greater_than', '20', '10', true ],
+ [ 'greater_than', '10', '10', false ],
+ [ 'less_than', '5', '10', true ],
+ [ 'less_than', '10', '10', false ],
+ [ 'gte', '10', '10', true ],
+ [ 'gte', '9', '10', false ],
+ [ 'lte', '10', '10', true ],
+ [ 'lte', '11', '10', false ],
+ ] )( '%s: %p vs %p', ( operator, actual, expected, want ) => {
+ expect( evaluateLogic( one( operator, expected ), types, { a: actual } ) ).toBe( want );
+ } );
+
+ it( 'fails the rule when either side is not numeric', () => {
+ expect( evaluateLogic( one( 'greater_than', '10' ), types, { a: 'abc' } ) ).toBe( false );
+ expect( evaluateLogic( one( 'greater_than', 'abc' ), types, { a: '20' } ) ).toBe( false );
+ expect( evaluateLogic( one( 'greater_than', '10' ), types, { a: '' } ) ).toBe( false );
+ } );
+
+ it( 'compares numbers numerically, not as strings', () => {
+ // '9' > '10' lexically, but 9 < 10 numerically.
+ expect( evaluateLogic( one( 'greater_than', '10' ), types, { a: '9' } ) ).toBe( false );
+ } );
+
+ it( 'accepts real numbers as values', () => {
+ expect( evaluateLogic( one( 'greater_than', 10 ), types, { a: 20 } ) ).toBe( true );
+ } );
+} );
+
+describe( 'evaluateLogic — date and time operators', () => {
+ it.each( [
+ [ 'before', '2026-01-01', '2026-06-01', true ],
+ [ 'before', '2026-12-01', '2026-06-01', false ],
+ [ 'after', '2026-12-01', '2026-06-01', true ],
+ [ 'after', '2026-01-01', '2026-06-01', false ],
+ [ 'is', '2026-06-01', '2026-06-01', true ],
+ [ 'is_not', '2026-06-02', '2026-06-01', true ],
+ ] )( 'date %s: %p vs %p', ( operator, actual, expected, want ) => {
+ expect( evaluateLogic( one( operator, expected ), { a: 'date' }, { a: actual } ) ).toBe( want );
+ } );
+
+ it.each( [
+ [ 'before', '09:00', '17:00', true ],
+ [ 'after', '18:00', '17:00', true ],
+ [ 'is', '17:00', '17:00', true ],
+ ] )( 'time %s: %p vs %p', ( operator, actual, expected, want ) => {
+ expect( evaluateLogic( one( operator, expected ), { a: 'time' }, { a: actual } ) ).toBe( want );
+ } );
+
+ it( 'fails the rule when either side is unparseable', () => {
+ expect( evaluateLogic( one( 'before', '2026-06-01' ), { a: 'date' }, { a: 'nonsense' } ) ).toBe(
+ false
+ );
+ expect( evaluateLogic( one( 'before', 'nonsense' ), { a: 'date' }, { a: '2026-06-01' } ) ).toBe(
+ false
+ );
+ } );
+} );
+
+describe( 'evaluateLogic — boolean operators', () => {
+ const types = { a: 'checkbox' };
+
+ it.each( [
+ [ 'is_checked', true, true ],
+ [ 'is_checked', false, false ],
+ [ 'is_checked', '1', true ],
+ [ 'is_checked', '', false ],
+ [ 'is_not_checked', false, true ],
+ [ 'is_not_checked', true, false ],
+ ] )( '%s: %p', ( operator, actual, want ) => {
+ expect( evaluateLogic( logic( [ { field: 'a', operator } ] ), types, { a: actual } ) ).toBe(
+ want
+ );
+ } );
+} );
+
+describe( 'evaluateLogic — combination, action, and ignored rules', () => {
+ const types = { a: 'text', b: 'text' };
+ const twoRules = [
+ { field: 'a', operator: 'is', value: 'x' },
+ { field: 'b', operator: 'is', value: 'y' },
+ ];
+
+ it( 'honors any versus all', () => {
+ const values = { a: 'x', b: 'nope' };
+ expect( evaluateLogic( logic( twoRules, { logicalOperator: 'any' } ), types, values ) ).toBe(
+ true
+ );
+ expect( evaluateLogic( logic( twoRules, { logicalOperator: 'all' } ), types, values ) ).toBe(
+ false
+ );
+ } );
+
+ it( 'inverts the outcome for the hide action', () => {
+ expect( evaluateLogic( one( 'is', 'x' ), types, { a: 'x' } ) ).toBe( true );
+ expect(
+ evaluateLogic(
+ logic( [ { field: 'a', operator: 'is', value: 'x' } ], { action: 'hide' } ),
+ types,
+ {
+ a: 'x',
+ }
+ )
+ ).toBe( false );
+ } );
+
+ it( 'is visible when disabled, ruleless, or missing its control', () => {
+ expect( evaluateLogic( logic( [] ), types, {} ) ).toBe( true );
+ expect(
+ evaluateLogic(
+ logic( [ { field: 'a', operator: 'is', value: 'x' } ], { enabled: false } ),
+ types,
+ {}
+ )
+ ).toBe( true );
+ expect(
+ evaluateLogic(
+ { enabled: true, action: 'show', logicalOperator: 'all', controls: {} },
+ types,
+ {}
+ )
+ ).toBe( true );
+ expect( evaluateLogic( null, types, {} ) ).toBe( true );
+ } );
+
+ it( 'ignores a rule whose subject field no longer exists', () => {
+ // A deleted subject must not be compared against empty — that would make is_empty
+ // spuriously true and hide the field because an unrelated block was removed.
+ expect( evaluateLogic( one( 'is_empty', '', 'gone_field' ), types, {} ) ).toBe( true );
+ expect( evaluateLogic( one( 'is', 'x', 'gone_field' ), types, {} ) ).toBe( true );
+ } );
+
+ it( 'ignores only the missing rule and still evaluates the rest', () => {
+ const rules = [
+ { field: 'gone_field', operator: 'is', value: 'x' },
+ { field: 'a', operator: 'is', value: 'x' },
+ ];
+ expect( evaluateLogic( logic( rules ), types, { a: 'x' } ) ).toBe( true );
+ expect( evaluateLogic( logic( rules ), types, { a: 'nope' } ) ).toBe( false );
+ } );
+
+ it( 'ignores a rule with an unknown operator', () => {
+ expect( evaluateLogic( one( 'not_a_real_operator', 'x' ), types, { a: 'x' } ) ).toBe( true );
+ } );
+} );
+
+describe( 'resolveVisibility — cascade', () => {
+ const chain = () => ( {
+ a: { logic: null, type: 'text' },
+ b: { logic: one( 'is', 'Other' ), type: 'text' },
+ c: { logic: logic( [ { field: 'b', operator: 'is_not_empty' } ] ), type: 'text' },
+ } );
+
+ it( 'treats a hidden field as empty for downstream rules', () => {
+ // A switched away from Other, so B hides. B still holds a stale value, but C must
+ // not stay visible on the strength of an answer the visitor can no longer see.
+ const visible = resolveVisibility( chain(), { a: 'Something else', b: 'xyz', c: '' } );
+ expect( visible.b ).toBe( false );
+ expect( visible.c ).toBe( false );
+ } );
+
+ it( 'keeps the chain visible when the trigger matches', () => {
+ const visible = resolveVisibility( chain(), { a: 'Other', b: 'xyz', c: '' } );
+ expect( visible.a ).toBe( true );
+ expect( visible.b ).toBe( true );
+ expect( visible.c ).toBe( true );
+ } );
+
+ it( 'fails open on a two-field cycle', () => {
+ const fields = {
+ a: { logic: logic( [ { field: 'b', operator: 'is_empty' } ] ), type: 'text' },
+ b: { logic: logic( [ { field: 'a', operator: 'is_not_empty' } ] ), type: 'text' },
+ };
+ const visible = resolveVisibility( fields, { a: 'x', b: 'y' } );
+ expect( visible.a ).toBe( true );
+ expect( visible.b ).toBe( true );
+ } );
+
+ it( 'fails open on self-reference', () => {
+ const fields = {
+ a: { logic: logic( [ { field: 'a', operator: 'is_empty' } ] ), type: 'text' },
+ };
+ expect( resolveVisibility( fields, { a: 'x' } ).a ).toBe( true );
+ } );
+
+ it( 'marks every field visible when none has logic', () => {
+ const fields = {
+ a: { logic: null, type: 'text' },
+ b: { logic: null, type: 'text' },
+ };
+ expect( resolveVisibility( fields, {} ) ).toEqual( { a: true, b: true } );
+ } );
+
+ it( 'resolves a three-deep chain in one call', () => {
+ const fields = {
+ a: { logic: null, type: 'text' },
+ b: { logic: one( 'is', 'go' ), type: 'text' },
+ c: { logic: logic( [ { field: 'b', operator: 'is_not_empty' } ] ), type: 'text' },
+ d: { logic: logic( [ { field: 'c', operator: 'is_not_empty' } ] ), type: 'text' },
+ };
+ const visible = resolveVisibility( fields, { a: 'go', b: 'x', c: 'y', d: '' } );
+ expect( visible ).toEqual( { a: true, b: true, c: true, d: true } );
+
+ const hidden = resolveVisibility( fields, { a: 'stop', b: 'x', c: 'y', d: '' } );
+ expect( hidden.b ).toBe( false );
+ expect( hidden.c ).toBe( false );
+ expect( hidden.d ).toBe( false );
+ } );
+
+ it( 'returns an entry for every field, including those without logic', () => {
+ const visible = resolveVisibility( chain(), { a: 'Other', b: 'x', c: '' } );
+ expect( Object.keys( visible ).sort() ).toEqual( [ 'a', 'b', 'c' ] );
+ } );
+
+ it( 'tolerates an empty field map', () => {
+ expect( resolveVisibility( {}, {} ) ).toEqual( {} );
+ } );
+
+ /**
+ * The pass budget used to be clamped to a constant, so an acyclic chain deeper than the
+ * clamp ran out of passes, was read as circular, and failed open -- leaving fields
+ * visible that every rule said to hide. Mirrors the PHP case of the same name.
+ */
+ it( 'resolves a chain deeper than the old pass cap', () => {
+ const depth = 30;
+ const fields = { f0: { type: 'text' } };
+ const values = { f0: 'no' };
+
+ for ( let i = 1; i <= depth; i++ ) {
+ fields[ `f${ i }` ] = {
+ type: 'text',
+ logic: {
+ enabled: true,
+ action: 'show',
+ logicalOperator: 'all',
+ groups: [
+ {
+ logicalOperator: 'all',
+ rules: [ { field: `f${ i - 1 }`, operator: 'is', value: 'yes' } ],
+ },
+ ],
+ },
+ };
+ values[ `f${ i }` ] = 'yes';
+ }
+
+ const visible = resolveVisibility( fields, values );
+
+ expect( visible.f0 ).toBe( true );
+ for ( let i = 1; i <= depth; i++ ) {
+ expect( visible[ `f${ i }` ] ).toBe( false );
+ }
+ } );
+} );
+
+/**
+ * Behavioural parity with the PHP evaluator.
+ *
+ * The PHP-side parity test pins the shared vocabulary -- operator names and the field type
+ * table -- but that is not where the two implementations drift: both the Date.parse/strtotime
+ * disagreement and the consent value-shape bug passed it. This reads the same table
+ * Conditional_Logic_Behaviour_Test.php does, so a comparison that behaves differently in the
+ * two languages fails on one side.
+ */
+describe( 'behaviour parity with PHP', () => {
+ const fixture = JSON.parse(
+ readFileSync(
+ path.join( process.cwd(), 'tests/fixtures/conditional-logic-behaviour.json' ),
+ 'utf8'
+ )
+ );
+
+ it.each( fixture.cases.map( testCase => [ testCase.name, testCase ] ) )(
+ '%s',
+ ( name, testCase ) => {
+ const caseLogic = {
+ enabled: true,
+ action: 'show',
+ logicalOperator: 'all',
+ groups: [
+ {
+ logicalOperator: 'all',
+ rules: [
+ {
+ field: 'subject',
+ operator: testCase.operator,
+ value: testCase.value,
+ },
+ ],
+ },
+ ],
+ };
+
+ const visible = evaluateLogic(
+ caseLogic,
+ { subject: testCase.type },
+ { subject: testCase.actual },
+ testCase.format ? { subject: testCase.format } : {}
+ );
+
+ expect( visible ).toBe( testCase.visible );
+ }
+ );
+} );
+
+/**
+ * The shape stores groups so that "any of these AND all of those" becomes possible without
+ * another migration. The V1 panel writes one group, but the evaluator has to handle several
+ * already -- otherwise the storage change buys nothing and the second group would arrive
+ * alongside an evaluator change anyway. Mirrored in Conditional_Logic_Test.php.
+ */
+describe( 'evaluateLogic — multiple groups', () => {
+ const types = { a: 'text', b: 'text', c: 'text' };
+
+ const twoGroups = ( outer, firstOperator, secondOperator ) => ( {
+ enabled: true,
+ action: 'show',
+ logicalOperator: outer,
+ groups: [
+ {
+ logicalOperator: firstOperator,
+ rules: [
+ { field: 'a', operator: 'is', value: 'yes' },
+ { field: 'b', operator: 'is', value: 'yes' },
+ ],
+ },
+ {
+ logicalOperator: secondOperator,
+ rules: [ { field: 'c', operator: 'is', value: 'yes' } ],
+ },
+ ],
+ } );
+
+ it( 'combines groups with all: both must hold', () => {
+ // First group satisfied by b alone, second group satisfied by c.
+ expect(
+ evaluateLogic( twoGroups( 'all', 'any', 'all' ), types, { a: 'no', b: 'yes', c: 'yes' } )
+ ).toBe( true );
+
+ // Second group fails, so the whole condition fails.
+ expect(
+ evaluateLogic( twoGroups( 'all', 'any', 'all' ), types, { a: 'no', b: 'yes', c: 'no' } )
+ ).toBe( false );
+ } );
+
+ it( 'combines groups with any: one is enough', () => {
+ // First group needs both and gets one; second group carries it.
+ expect(
+ evaluateLogic( twoGroups( 'any', 'all', 'all' ), types, { a: 'no', b: 'yes', c: 'yes' } )
+ ).toBe( true );
+
+ expect(
+ evaluateLogic( twoGroups( 'any', 'all', 'all' ), types, { a: 'no', b: 'yes', c: 'no' } )
+ ).toBe( false );
+ } );
+
+ it( 'ignores a group with nothing it can evaluate', () => {
+ // The second group names a field that no longer exists, so it drops out rather than
+ // dragging the field into hiding under an `all`.
+ const visible = evaluateLogic(
+ {
+ enabled: true,
+ action: 'show',
+ logicalOperator: 'all',
+ groups: [
+ { logicalOperator: 'all', rules: [ { field: 'a', operator: 'is', value: 'yes' } ] },
+ { logicalOperator: 'all', rules: [ { field: 'gone', operator: 'is', value: 'yes' } ] },
+ ],
+ },
+ types,
+ { a: 'yes' }
+ );
+
+ expect( visible ).toBe( true );
+ } );
+
+ it( 'ignores a rule of a kind it does not know', () => {
+ // A form saved by a newer editor degrades to the conditions this release understands
+ // rather than breaking on the ones it does not.
+ const visible = evaluateLogic(
+ {
+ enabled: true,
+ action: 'show',
+ logicalOperator: 'all',
+ groups: [
+ {
+ logicalOperator: 'all',
+ rules: [
+ { type: 'fieldValue', field: 'a', operator: 'is', value: 'yes' },
+ { type: 'queryString', field: 'utm_source', operator: 'is', value: 'ads' },
+ ],
+ },
+ ],
+ },
+ types,
+ { a: 'yes' }
+ );
+
+ expect( visible ).toBe( true );
+ } );
+} );
diff --git a/projects/packages/forms/tests/js/blocks/shared/conditional-logic/field-options.test.js b/projects/packages/forms/tests/js/blocks/shared/conditional-logic/field-options.test.js
new file mode 100644
index 000000000000..d567986a2eb1
--- /dev/null
+++ b/projects/packages/forms/tests/js/blocks/shared/conditional-logic/field-options.test.js
@@ -0,0 +1,144 @@
+import { getFieldOptions } from '../../../../../src/blocks/shared/conditional-logic/util/field-options';
+
+const option = label => ( { name: 'jetpack/option', attributes: { label }, innerBlocks: [] } );
+const imageOption = label => ( {
+ name: 'jetpack/input-image-option',
+ attributes: { label },
+ innerBlocks: [],
+} );
+
+describe( 'getFieldOptions', () => {
+ it( 'reads the options string array from field-select', () => {
+ const block = {
+ name: 'jetpack/field-select',
+ attributes: { options: [ 'Small', 'Medium', 'Large' ] },
+ innerBlocks: [],
+ };
+ expect( getFieldOptions( block ) ).toEqual( [
+ { value: 'Small', label: 'Small' },
+ { value: 'Medium', label: 'Medium' },
+ { value: 'Large', label: 'Large' },
+ ] );
+ } );
+
+ // field-single-choice templates [ 'jetpack/options', { type: 'radio' } ], so the
+ // option blocks sit one level below the field, not as direct children.
+ it( 'reads option blocks nested under a jetpack/options wrapper', () => {
+ const block = {
+ name: 'jetpack/field-single-choice',
+ attributes: {},
+ innerBlocks: [
+ { name: 'jetpack/label', attributes: { label: 'Pick one' }, innerBlocks: [] },
+ {
+ name: 'jetpack/options',
+ attributes: { type: 'radio' },
+ innerBlocks: [ option( 'Yes' ), option( 'No' ) ],
+ },
+ ],
+ };
+ expect( getFieldOptions( block ) ).toEqual( [
+ { value: 'Yes', label: 'Yes' },
+ { value: 'No', label: 'No' },
+ ] );
+ } );
+
+ it( 'reads option blocks that are direct children', () => {
+ const block = {
+ name: 'jetpack/field-multiple-choice',
+ attributes: {},
+ innerBlocks: [ option( 'A' ), option( 'B' ) ],
+ };
+ expect( getFieldOptions( block ) ).toEqual( [
+ { value: 'A', label: 'A' },
+ { value: 'B', label: 'B' },
+ ] );
+ } );
+
+ it( 'reads image options from a fieldset-image-options wrapper', () => {
+ const block = {
+ name: 'jetpack/field-image-select',
+ attributes: {},
+ innerBlocks: [
+ {
+ name: 'jetpack/fieldset-image-options',
+ attributes: {},
+ innerBlocks: [ imageOption( 'Cat' ), imageOption( 'Dog' ) ],
+ },
+ ],
+ };
+ expect( getFieldOptions( block ) ).toEqual( [
+ { value: 'Cat', label: 'Cat' },
+ { value: 'Dog', label: 'Dog' },
+ ] );
+ } );
+
+ it( 'trims labels, skips blanks and de-duplicates', () => {
+ const block = {
+ name: 'jetpack/field-single-choice',
+ attributes: {},
+ innerBlocks: [
+ {
+ name: 'jetpack/options',
+ attributes: {},
+ innerBlocks: [ option( ' Yes ' ), option( '' ), option( 'Yes' ), option( ' ' ) ],
+ },
+ ],
+ };
+ expect( getFieldOptions( block ) ).toEqual( [ { value: 'Yes', label: 'Yes' } ] );
+ } );
+
+ it( 'ignores the label block so a field label never becomes an option', () => {
+ const block = {
+ name: 'jetpack/field-single-choice',
+ attributes: {},
+ innerBlocks: [
+ { name: 'jetpack/label', attributes: { label: 'Not an option' }, innerBlocks: [] },
+ { name: 'jetpack/options', attributes: {}, innerBlocks: [ option( 'Real' ) ] },
+ ],
+ };
+ expect( getFieldOptions( block ) ).toEqual( [ { value: 'Real', label: 'Real' } ] );
+ } );
+
+ it( 'returns an empty array for fields with no options', () => {
+ expect(
+ getFieldOptions( { name: 'jetpack/field-text', attributes: {}, innerBlocks: [] } )
+ ).toEqual( [] );
+ expect( getFieldOptions( null ) ).toEqual( [] );
+ expect( getFieldOptions( undefined ) ).toEqual( [] );
+ } );
+
+ it( 'tolerates a field-select with a malformed options attribute', () => {
+ expect(
+ getFieldOptions( {
+ name: 'jetpack/field-select',
+ attributes: { options: 'nope' },
+ innerBlocks: [],
+ } )
+ ).toEqual( [] );
+ expect(
+ getFieldOptions( {
+ name: 'jetpack/field-select',
+ attributes: { options: [ '', null, 'Only' ] },
+ innerBlocks: [],
+ } )
+ ).toEqual( [ { value: 'Only', label: 'Only' } ] );
+ } );
+} );
+
+describe( 'getFieldOptions — rating', () => {
+ /**
+ * A rating has no option blocks. Its choices are its own scale, so a rule can only be
+ * built against it if the scale is offered as the values.
+ */
+ it( 'offers the configured scale', () => {
+ expect( getFieldOptions( { name: 'jetpack/field-rating', attributes: { max: 3 } } ) ).toEqual( [
+ { value: '1', label: '1' },
+ { value: '2', label: '2' },
+ { value: '3', label: '3' },
+ ] );
+ } );
+
+ it( 'falls back to five when no maximum is set', () => {
+ expect( getFieldOptions( { name: 'jetpack/field-rating', attributes: {} } ) ).toHaveLength( 5 );
+ } );
+} );
diff --git a/projects/packages/forms/tests/js/blocks/shared/conditional-logic/field-types.test.js b/projects/packages/forms/tests/js/blocks/shared/conditional-logic/field-types.test.js
new file mode 100644
index 000000000000..3aa6f2cd5288
--- /dev/null
+++ b/projects/packages/forms/tests/js/blocks/shared/conditional-logic/field-types.test.js
@@ -0,0 +1,90 @@
+import {
+ OPERATORS,
+ getOperatorsForTypeKey,
+ getValueInputForTypeKey,
+ operatorNeedsValue,
+} from '../../../../../src/blocks/shared/conditional-logic/util/field-types';
+
+/**
+ * One row per comparison behavior: [ type key, value input kind ].
+ *
+ * Which block declares which type key is not asserted here — the blocks own that, and
+ * block-names.test.js checks it against their source. This file covers what the type key
+ * itself buys you: the operator set and the value input the rule builder renders.
+ */
+const CASES = [
+ [ 'string', 'text' ],
+ [ 'choice', 'options' ],
+ [ 'multichoice', 'options' ],
+ [ 'number', 'number' ],
+ [ 'rating', 'options' ],
+ [ 'date', 'date' ],
+ [ 'time', 'time' ],
+ [ 'boolean', 'none' ],
+ [ 'hidden', 'text' ],
+ [ 'file', 'none' ],
+];
+
+const EXPECTED_OPERATORS = {
+ string: [ 'is', 'is_not', 'contains', 'does_not_contain', 'is_empty', 'is_not_empty' ],
+ choice: [ 'is', 'is_not', 'is_empty', 'is_not_empty' ],
+ multichoice: [ 'contains', 'does_not_contain', 'is_empty', 'is_not_empty' ],
+ number: [
+ 'equals',
+ 'not_equals',
+ 'greater_than',
+ 'less_than',
+ 'gte',
+ 'lte',
+ 'is_empty',
+ 'is_not_empty',
+ ],
+ // Compares like a number, but offers its own scale as the values.
+ rating: [
+ 'equals',
+ 'not_equals',
+ 'greater_than',
+ 'less_than',
+ 'gte',
+ 'lte',
+ 'is_empty',
+ 'is_not_empty',
+ ],
+ date: [ 'is', 'is_not', 'before', 'after' ],
+ time: [ 'is', 'is_not', 'before', 'after' ],
+ boolean: [ 'is_checked', 'is_not_checked' ],
+ hidden: [ 'is', 'is_not', 'contains' ],
+ file: [ 'is_empty', 'is_not_empty' ],
+};
+
+describe( 'field-types', () => {
+ it( 'covers every comparison behavior a block can declare', () => {
+ expect( CASES.map( ( [ typeKey ] ) => typeKey ).sort() ).toEqual(
+ Object.keys( EXPECTED_OPERATORS ).sort()
+ );
+ } );
+
+ it.each( CASES )( '%s renders a %s value input', ( typeKey, input ) => {
+ expect( getValueInputForTypeKey( typeKey ) ).toBe( input );
+ } );
+
+ it.each( CASES )( '%s exposes its operator set', typeKey => {
+ expect( getOperatorsForTypeKey( typeKey ) ).toEqual( EXPECTED_OPERATORS[ typeKey ] );
+ } );
+
+ it( 'returns an empty operator list for an unknown type key', () => {
+ expect( getOperatorsForTypeKey( 'nonsense' ) ).toEqual( [] );
+ expect( getValueInputForTypeKey( 'nonsense' ) ).toBe( 'text' );
+ } );
+
+ it( 'knows which operators take no value operand', () => {
+ expect( operatorNeedsValue( OPERATORS.IS ) ).toBe( true );
+ expect( operatorNeedsValue( OPERATORS.CONTAINS ) ).toBe( true );
+ expect( operatorNeedsValue( OPERATORS.GREATER_THAN ) ).toBe( true );
+ expect( operatorNeedsValue( OPERATORS.BEFORE ) ).toBe( true );
+ expect( operatorNeedsValue( OPERATORS.IS_EMPTY ) ).toBe( false );
+ expect( operatorNeedsValue( OPERATORS.IS_NOT_EMPTY ) ).toBe( false );
+ expect( operatorNeedsValue( OPERATORS.IS_CHECKED ) ).toBe( false );
+ expect( operatorNeedsValue( OPERATORS.IS_NOT_CHECKED ) ).toBe( false );
+ } );
+} );
diff --git a/projects/packages/forms/tests/js/blocks/shared/conditional-logic/operator-labels.test.js b/projects/packages/forms/tests/js/blocks/shared/conditional-logic/operator-labels.test.js
new file mode 100644
index 000000000000..b8c69a1aab68
--- /dev/null
+++ b/projects/packages/forms/tests/js/blocks/shared/conditional-logic/operator-labels.test.js
@@ -0,0 +1,30 @@
+import { OPERATORS } from '../../../../../src/blocks/shared/conditional-logic/util/field-types';
+import {
+ OPERATOR_LABELS,
+ getOperatorLabel,
+} from '../../../../../src/blocks/shared/conditional-logic/util/operator-labels';
+
+describe( 'operator labels', () => {
+ it( 'labels every operator', () => {
+ const operators = Object.values( OPERATORS );
+ const labelled = Object.keys( OPERATOR_LABELS );
+ expect( labelled.sort() ).toEqual( [ ...operators ].sort() );
+ } );
+
+ // Some labels legitimately read the same as their wire string ("is", "contains",
+ // "equals"), so only blankness is a defect here.
+ it( 'has no blank labels', () => {
+ Object.values( OPERATOR_LABELS ).forEach( label => {
+ expect( typeof label ).toBe( 'string' );
+ expect( label.trim() ).not.toBe( '' );
+ } );
+ } );
+
+ it( 'falls back to the wire string for an unknown operator', () => {
+ expect( getOperatorLabel( 'not_a_real_operator' ) ).toBe( 'not_a_real_operator' );
+ } );
+
+ it( 'returns the label for a known operator', () => {
+ expect( getOperatorLabel( OPERATORS.GREATER_THAN ) ).toBe( 'is greater than' );
+ } );
+} );
diff --git a/projects/packages/forms/tests/js/blocks/shared/conditional-logic/panel.test.jsx b/projects/packages/forms/tests/js/blocks/shared/conditional-logic/panel.test.jsx
new file mode 100644
index 000000000000..ff7ef62dbe1b
--- /dev/null
+++ b/projects/packages/forms/tests/js/blocks/shared/conditional-logic/panel.test.jsx
@@ -0,0 +1,650 @@
+import { describe, expect, it, jest } from '@jest/globals';
+import { render, screen, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { useCallback, useState } from 'react';
+
+const SUBJECT_FIELDS = [
+ {
+ clientId: 'c-name',
+ id: 'name_1',
+ label: 'Name',
+ typeLabel: 'Name field',
+ typeKey: 'string',
+ options: [],
+ step: null,
+ },
+ {
+ clientId: 'c-budget',
+ id: 'budget_1',
+ label: 'Budget',
+ typeLabel: 'Number input field',
+ typeKey: 'number',
+ options: [],
+ step: null,
+ },
+ {
+ clientId: 'c-size',
+ id: 'size_1',
+ label: 'Size',
+ typeLabel: 'Dropdown field',
+ typeKey: 'choice',
+ options: [
+ { value: 'Small', label: 'Small' },
+ { value: 'Large', label: 'Large' },
+ ],
+ step: null,
+ },
+ {
+ clientId: 'c-terms',
+ id: 'terms_1',
+ label: 'Terms',
+ typeLabel: 'Checkbox',
+ typeKey: 'boolean',
+ options: [],
+ step: null,
+ },
+ // The common case: a field the author never gave an explicit id.
+ {
+ clientId: 'c-colour',
+ id: '',
+ label: 'Untitled field',
+ typeLabel: 'Text input field',
+ typeKey: 'string',
+ options: [],
+ step: null,
+ },
+];
+
+const mockToggleBlockHighlight = jest.fn();
+
+await jest.unstable_mockModule( '@wordpress/block-editor', () => ( {
+ InspectorControls: ( { children } ) =>
{ children }
,
+ BlockControls: ( { children } ) =>
{ children }
,
+ store: 'core/block-editor',
+} ) );
+
+// Only useDispatch is replaced. Something in the panel's import graph pulls the real store
+// helpers from this module, so a mock that omits them fails at import rather than in a test --
+// and one that imports the module from inside its own factory recurses until V8 gives up.
+// Loading it first, then registering the mock, avoids both.
+const actualData = await import( '@wordpress/data' );
+
+await jest.unstable_mockModule( '@wordpress/data', () => ( {
+ ...actualData,
+ useDispatch: () => ( { toggleBlockHighlight: mockToggleBlockHighlight } ),
+} ) );
+
+const mockEnsureFieldId = jest.fn( ( field, usedIds = [] ) => {
+ if ( field?.id ) {
+ return field.id;
+ }
+ // Mirrors the real hook: slugify the label, de-duplicate against ids already in use.
+ const base = ( field?.label || '' ).trim().toLowerCase().replace( /\s+/g, '-' );
+ return usedIds.includes( base ) ? `${ base }-2` : base;
+} );
+
+await jest.unstable_mockModule(
+ '../../../../../src/blocks/shared/conditional-logic/hooks/use-subject-fields.js',
+ () => ( {
+ __esModule: true,
+ default: () => SUBJECT_FIELDS,
+ useEnsureFieldId: () => mockEnsureFieldId,
+ } )
+);
+
+const { default: ConditionalLogicPanel } = await import(
+ '../../../../../src/blocks/shared/conditional-logic/components/panel.jsx'
+);
+
+const DEFAULT_ATTRIBUTE = {
+ enabled: false,
+ action: 'show',
+ logicalOperator: 'any',
+ groups: [],
+};
+
+const withRules = ( rules, extra = {} ) => ( {
+ enabled: true,
+ action: 'show',
+ logicalOperator: 'all',
+ groups: [ { logicalOperator: 'all', rules } ],
+ ...extra,
+} );
+
+const setup = async (
+ conditionalLogic = DEFAULT_ATTRIBUTE,
+ { openModal = true, ownFieldId } = {}
+) => {
+ const setAttributes = jest.fn();
+ const { container } = render(
+
+ );
+
+ // PanelBody renders collapsed (initialOpen={false}), so nothing inside it exists
+ // in the DOM until the title is activated.
+ await userEvent.click( screen.getByRole( 'button', { name: 'Conditional logic' } ) );
+
+ // The rules are edited in a dialog, so most of this file has to open it first. Tests
+ // about what the inspector itself shows pass openModal: false.
+ if ( openModal ) {
+ await userEvent.click( screen.getByRole( 'button', { name: /(add|edit) conditions/i } ) );
+ }
+
+ return { setAttributes, container };
+};
+
+/**
+ * Render the panel with state, so an edit comes back as props the way the editor does.
+ *
+ * @param {object} initial - The starting conditionalLogic attribute.
+ */
+const setupStateful = async initial => {
+ const Harness = () => {
+ const [ attributes, setAttributes ] = useState( { conditionalLogic: initial } );
+ const apply = useCallback(
+ next => setAttributes( current => ( { ...current, ...next } ) ),
+ []
+ );
+
+ return (
+
+ );
+ };
+
+ render( );
+ await userEvent.click( screen.getByRole( 'button', { name: 'Conditional logic' } ) );
+ await userEvent.click( screen.getByRole( 'button', { name: /(add|edit) conditions/i } ) );
+};
+
+const optionValues = select =>
+ within( select )
+ .getAllByRole( 'option' )
+ .map( o => o.value );
+
+describe( 'ConditionalLogicPanel', () => {
+ it( 'renders the panel title', async () => {
+ await setup( DEFAULT_ATTRIBUTE, { openModal: false } );
+ expect( screen.getByText( 'Conditional logic' ) ).toBeInTheDocument();
+ } );
+
+ // The inspector keeps a summary so an author can tell what a field does without opening
+ // the dialog. That is the only reason it still holds a panel rather than a bare button.
+ it( 'summarises the conditions in the inspector', async () => {
+ await setup(
+ withRules( [
+ { field: 'name_1', operator: 'is', value: 'x' },
+ { field: 'budget_1', operator: 'gte', value: '5' },
+ ] ),
+ { openModal: false }
+ );
+
+ // The conditions themselves, not a count of them: an author should be able to read what
+ // the field does without opening the dialog.
+ expect( screen.getByText( 'This field is shown only if:' ) ).toBeInTheDocument();
+ expect( screen.getByText( 'Name (Name field) is “x”' ) ).toBeInTheDocument();
+ expect( screen.getByText( 'Budget (Number input field) is at least “5”' ) ).toBeInTheDocument();
+ expect( screen.getByRole( 'button', { name: 'Edit conditions' } ) ).toBeInTheDocument();
+ } );
+
+ // The same store action the block list uses, so pointing at a condition shows you which
+ // field it refers to on the canvas rather than leaving you to find it by name.
+ it( 'highlights the subject block while a condition is hovered', async () => {
+ mockToggleBlockHighlight.mockClear();
+ await setup( withRules( [ { field: 'name_1', operator: 'is', value: 'x' } ] ), {
+ openModal: false,
+ } );
+
+ // Hovering the text is enough: React fires onMouseEnter on the line when the pointer
+ // enters any of its children.
+ const line = screen.getByText( 'Name (Name field) is “x”' );
+
+ await userEvent.hover( line );
+ expect( mockToggleBlockHighlight ).toHaveBeenCalledWith( 'c-name', true );
+
+ await userEvent.unhover( line );
+ expect( mockToggleBlockHighlight ).toHaveBeenCalledWith( 'c-name', false );
+ } );
+
+ it( 'summarises a hide action and an any match', async () => {
+ await setup(
+ withRules( [ { field: 'name_1', operator: 'is', value: 'x' } ], { action: 'hide' } ),
+ { openModal: false }
+ );
+
+ expect( screen.getByText( 'This field is hidden only if:' ) ).toBeInTheDocument();
+ expect( screen.getByText( 'Name (Name field) is “x”' ) ).toBeInTheDocument();
+ } );
+
+ // The toolbar button reports the same thing the inspector summary does, for an author who
+ // is looking at the canvas rather than the sidebar.
+ it( 'adds a toolbar button once the field has conditions', async () => {
+ await setup( withRules( [ { field: 'name_1', operator: 'is', value: 'x' } ] ), {
+ openModal: false,
+ } );
+
+ expect(
+ screen.getByRole( 'button', {
+ name: 'This field is shown only if: Name (Name field) is “x”',
+ } )
+ ).toBeInTheDocument();
+ } );
+
+ // The same treatment Required uses: inverted while the field carries conditions.
+ it( 'inverts the toolbar button while conditions exist', async () => {
+ await setup( withRules( [ { field: 'name_1', operator: 'is', value: 'x' } ] ), {
+ openModal: false,
+ } );
+
+ expect(
+ screen.getByRole( 'button', {
+ name: 'This field is shown only if: Name (Name field) is “x”',
+ } )
+ ).toHaveClass( 'is-pressed' );
+ } );
+
+ // Present on every block that supports conditional logic, the way Required is: a control
+ // that comes and goes is harder to find than one that is always there.
+ it( 'offers the toolbar button before any condition exists', async () => {
+ await setup( DEFAULT_ATTRIBUTE, { openModal: false } );
+
+ const button = screen.getByRole( 'button', { name: 'Add conditional logic' } );
+
+ expect( button ).toBeInTheDocument();
+ expect( button ).not.toHaveClass( 'is-pressed' );
+ } );
+
+ it( 'opens the builder from the toolbar before any condition exists', async () => {
+ await setup( DEFAULT_ATTRIBUTE, { openModal: false } );
+
+ await userEvent.click( screen.getByRole( 'button', { name: 'Add conditional logic' } ) );
+
+ expect( screen.getByRole( 'dialog' ) ).toBeInTheDocument();
+ } );
+
+ it( 'opens the dialog from the toolbar button', async () => {
+ await setup( withRules( [ { field: 'name_1', operator: 'is', value: 'x' } ] ), {
+ openModal: false,
+ } );
+
+ await userEvent.click(
+ screen.getByRole( 'button', {
+ name: 'This field is shown only if: Name (Name field) is “x”',
+ } )
+ );
+
+ expect( screen.getByRole( 'dialog' ) ).toBeInTheDocument();
+ } );
+
+ it( 'invites the author in when there are no conditions yet', async () => {
+ await setup( DEFAULT_ATTRIBUTE, { openModal: false } );
+
+ expect(
+ screen.getByText( 'Show or hide this field based on the answer to another field.' )
+ ).toBeInTheDocument();
+ expect( screen.getByRole( 'button', { name: 'Add conditions' } ) ).toBeInTheDocument();
+ } );
+
+ it( 'does not render the rule builder until the dialog is opened', async () => {
+ await setup( DEFAULT_ATTRIBUTE, { openModal: false } );
+
+ expect( screen.queryByLabelText( 'Action' ) ).not.toBeInTheDocument();
+ expect( screen.queryByRole( 'button', { name: 'Add condition' } ) ).not.toBeInTheDocument();
+ } );
+
+ // The builder opens with a waiting row, and that row is unfinished, so there is nothing
+ // to add yet -- the button is absent rather than present and dead.
+ // Always offered: withholding it stopped an author adding a second condition while the
+ // first was still being written, which is a normal way to work.
+ it( 'offers Add condition even while a row is unfinished', async () => {
+ await setup();
+ expect( screen.getByRole( 'button', { name: /add condition/i } ) ).toBeInTheDocument();
+ } );
+
+ // `enabled` is derived from whether any rule exists, so a field only carries conditional
+ // logic once it actually has a condition.
+ // The builder opens with a condition waiting rather than an empty pane and an Add button:
+ // an author should not have to press anything to start.
+ it( 'opens with a condition ready to fill in', async () => {
+ const { setAttributes } = await setup();
+
+ expect( screen.getByLabelText( 'Field' ) ).toBeInTheDocument();
+ // Waiting, not written: opening the dialog must not mark the post as changed.
+ expect( setAttributes ).not.toHaveBeenCalled();
+ } );
+
+ it( 'enables logic once the waiting condition names a field', async () => {
+ const { setAttributes } = await setup();
+
+ await userEvent.selectOptions( screen.getByLabelText( 'Field' ), 'budget_1' );
+
+ expect( setAttributes ).toHaveBeenCalledWith( {
+ conditionalLogic: expect.objectContaining( {
+ enabled: true,
+ groups: [
+ {
+ logicalOperator: 'any',
+ rules: [ expect.objectContaining( { type: 'fieldValue', field: 'budget_1' } ) ],
+ },
+ ],
+ } ),
+ } );
+ } );
+
+ it( 'disables logic again when the last condition is removed', async () => {
+ const { setAttributes } = await setup(
+ withRules( [ { field: 'name_1', operator: 'is', value: 'x' } ] )
+ );
+
+ await userEvent.click( screen.getByRole( 'button', { name: 'Remove condition 1' } ) );
+
+ expect( setAttributes ).toHaveBeenCalledWith( {
+ conditionalLogic: expect.objectContaining( {
+ enabled: false,
+ // The group goes with its last rule, back to the default empty state rather
+ // than a hollow group nothing reads.
+ groups: [],
+ } ),
+ } );
+ } );
+
+ // Both selectors sit inside the dialog's sentence, so they are available whether or not a
+ // condition exists yet -- unlike the old inspector layout, which hid them until the first
+ // rule was added to keep the column short.
+ it( 'offers the action and match selectors in the dialog', async () => {
+ await setup();
+ expect( screen.getByLabelText( 'Action' ) ).toBeInTheDocument();
+ expect( screen.getByLabelText( 'When' ) ).toBeInTheDocument();
+ } );
+
+ // The row arrangement itself is CSS; what this can verify is that the selectors are the
+ // words of a sentence rather than labelled fields stacked above the rules.
+ it( 'reads as a sentence around the selectors', async () => {
+ // Queried through `screen`, not the render container: the dialog portals to the end of
+ // the document, so it is not a descendant of what render() returns.
+ await setup( withRules( [ { field: 'name_1', operator: 'is', value: 'x' } ] ) );
+
+ // The clause finishing the sentence sits underneath rather than being interleaved
+ // with the controls, and states the default the selectors do not.
+ expect(
+ screen.getByText( 'This field is hidden by default, until the following conditions are met:' )
+ ).toBeInTheDocument();
+ } );
+
+ // A hide rule inverts the default: the field is there until something removes it.
+ it( 'states the opposite default for a hide action', async () => {
+ await setup(
+ withRules( [ { field: 'name_1', operator: 'is', value: 'x' } ], { action: 'hide' } )
+ );
+
+ expect(
+ screen.getByText(
+ 'This field is visible by default, until the following conditions are met:'
+ )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'phrases the selectors to read on from each other', async () => {
+ await setup( withRules( [ { field: 'name_1', operator: 'is', value: 'x' } ] ) );
+
+ const action = screen.getByLabelText( 'Action' );
+ expect( optionValues( action ) ).toEqual( [ 'show', 'hide' ] );
+ expect(
+ within( action ).getByRole( 'option', { name: 'Show this field' } )
+ ).toBeInTheDocument();
+
+ const match = screen.getByLabelText( 'When' );
+ expect( optionValues( match ) ).toEqual( [ 'any', 'all' ] );
+ expect( within( match ).getByRole( 'option', { name: 'if any' } ) ).toBeInTheDocument();
+ expect( within( match ).getByRole( 'option', { name: 'if all' } ) ).toBeInTheDocument();
+ } );
+
+ it( 'offers the operators belonging to the subject field type', async () => {
+ await setup( withRules( [ { field: 'budget_1', operator: 'greater_than', value: '10' } ] ) );
+
+ const operator = screen.getByLabelText( 'Operator' );
+ const values = optionValues( operator );
+
+ expect( values ).toEqual( [
+ 'equals',
+ 'not_equals',
+ 'greater_than',
+ 'less_than',
+ 'gte',
+ 'lte',
+ 'is_empty',
+ 'is_not_empty',
+ ] );
+ } );
+
+ it( 'offers string operators for a text subject field', async () => {
+ await setup( withRules( [ { field: 'name_1', operator: 'is', value: 'x' } ] ) );
+
+ const operator = screen.getByLabelText( 'Operator' );
+ const values = optionValues( operator );
+
+ expect( values ).toEqual( [
+ 'is',
+ 'is_not',
+ 'contains',
+ 'does_not_contain',
+ 'is_empty',
+ 'is_not_empty',
+ ] );
+ } );
+
+ it( 'renders the value as a dropdown of the subject field own options', async () => {
+ await setup( withRules( [ { field: 'size_1', operator: 'is', value: 'Small' } ] ) );
+
+ const value = screen.getByLabelText( 'Value' );
+ const options = optionValues( value );
+
+ expect( options ).toEqual( [ '', 'Small', 'Large' ] );
+ } );
+
+ it( 'renders a number input for a numeric subject field', async () => {
+ await setup( withRules( [ { field: 'budget_1', operator: 'greater_than', value: '10' } ] ) );
+ expect( screen.getByLabelText( 'Value' ) ).toHaveAttribute( 'type', 'number' );
+ } );
+
+ it( 'renders no value input for operators that take no operand', async () => {
+ await setup( withRules( [ { field: 'name_1', operator: 'is_empty' } ] ) );
+ expect( screen.queryByLabelText( 'Value' ) ).not.toBeInTheDocument();
+ } );
+
+ it( 'renders no value input for a boolean subject field', async () => {
+ await setup( withRules( [ { field: 'terms_1', operator: 'is_checked' } ] ) );
+ expect( screen.queryByLabelText( 'Value' ) ).not.toBeInTheDocument();
+ } );
+
+ it( 'lists every sibling field, including ones with no explicit id', async () => {
+ await setup( withRules( [ { field: 'name_1', operator: 'is', value: 'x' } ] ) );
+
+ const field = screen.getByLabelText( 'Field' );
+ const values = optionValues( field );
+
+ expect( values ).toEqual( [
+ '',
+ 'name_1',
+ 'budget_1',
+ 'size_1',
+ 'terms_1',
+ 'clientId:c-colour',
+ ] );
+ } );
+
+ // An author cannot tell two "Untitled field" entries apart, so the dropdown appends the
+ // field's own block title.
+ it( 'shows the field type alongside each label', async () => {
+ await setup( withRules( [ { field: 'name_1', operator: 'is', value: 'x' } ] ) );
+
+ const field = screen.getByLabelText( 'Field' );
+
+ expect(
+ within( field ).getByRole( 'option', { name: 'Name (Name field)' } )
+ ).toBeInTheDocument();
+ expect(
+ within( field ).getByRole( 'option', { name: 'Budget (Number input field)' } )
+ ).toBeInTheDocument();
+ expect(
+ within( field ).getByRole( 'option', { name: 'Untitled field (Text input field)' } )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'warns when a rule references a field that no longer exists', async () => {
+ await setup( withRules( [ { field: 'deleted_1', operator: 'is', value: 'x' } ] ) );
+
+ // Scoped to the dialog: Notice mirrors its text into an aria-live region that
+ // WordPress appends to document.body, so an unscoped query matches twice.
+ expect(
+ within( screen.getByRole( 'dialog' ) ).getByText( /no longer exists/i )
+ ).toBeInTheDocument();
+ } );
+
+ // A condition naming no subject, or giving no value where one is needed, is skipped by both
+ // evaluators. Letting an author stack up rules that quietly do nothing is the trap here.
+ // A condition naming no subject, or giving no value where one is needed, is skipped by both
+ // evaluators. Letting an author stack up rules that quietly do nothing is the trap here.
+ // A condition naming no subject, or giving no value where one is needed, is skipped by
+ // both evaluators. The badge makes that visible rather than the field silently not
+ // reacting, which is why the Add button no longer has to police it.
+ it( 'flags an unfinished condition and says what to do', async () => {
+ await setup( withRules( [ { field: 'name_1', operator: 'is', value: '' } ] ) );
+
+ // Queried by label, not text: a tooltip renders nothing until hovered, so the reason
+ // has to be on the icon itself to be reachable at all.
+ expect( screen.getByLabelText( 'Give this condition a value.' ) ).toBeInTheDocument();
+ expect( screen.queryByLabelText( 'This condition is active.' ) ).not.toBeInTheDocument();
+ } );
+
+ // A new condition appears empty, so the first thing to do with it is choose a subject.
+ // This also tells a screen-reader user the row exists at all.
+ it( 'moves focus to the new condition after adding one', async () => {
+ // Rendered with real state rather than a jest.fn(): adding a condition only shows up
+ // if the new attribute comes back as props, which a mock setter never does.
+ await setupStateful( withRules( [ { field: 'name_1', operator: 'is', value: 'x' } ] ) );
+
+ await userEvent.click( screen.getByRole( 'button', { name: 'Add condition' } ) );
+
+ const fieldSelects = screen.getAllByLabelText( 'Field' );
+ expect( fieldSelects ).toHaveLength( 2 );
+ expect( fieldSelects[ 1 ] ).toHaveFocus();
+ } );
+
+ it( 'does not steal focus when the dialog first opens', async () => {
+ await setup( withRules( [ { field: 'name_1', operator: 'is', value: 'x' } ] ) );
+
+ expect( screen.getByLabelText( 'Field' ) ).not.toHaveFocus();
+ } );
+
+ it( 'allows a second condition once the first is complete', async () => {
+ await setup( withRules( [ { field: 'name_1', operator: 'is', value: 'x' } ] ) );
+
+ expect( screen.getByRole( 'button', { name: 'Add condition' } ) ).toBeEnabled();
+ } );
+
+ it( 'marks a complete condition as active', async () => {
+ await setup( withRules( [ { field: 'name_1', operator: 'is', value: 'x' } ] ) );
+
+ expect( screen.getByLabelText( 'This condition is active.' ) ).toBeInTheDocument();
+ } );
+
+ it( 'explains an inactive condition whose field was deleted', async () => {
+ await setup( withRules( [ { field: 'deleted_1', operator: 'is', value: 'x' } ] ) );
+
+ expect(
+ screen.getByLabelText( 'The field this condition refers to no longer exists.' )
+ ).toBeInTheDocument();
+ } );
+
+ // Regression: fields whose id the renderer derives at output time were filtered out of
+ // the dropdown, leaving only the Name field, which ships explicit default ids.
+ it( 'assigns an id when a field without one is chosen as the subject', async () => {
+ mockEnsureFieldId.mockClear();
+ const { setAttributes } = await setup(
+ withRules( [ { field: 'name_1', operator: 'is', value: 'x' } ] )
+ );
+
+ await userEvent.selectOptions( screen.getByLabelText( 'Field' ), 'clientId:c-colour' );
+
+ expect( mockEnsureFieldId ).toHaveBeenCalledWith(
+ expect.objectContaining( { clientId: 'c-colour', id: '' } ),
+ expect.arrayContaining( [ 'name_1', 'budget_1' ] )
+ );
+ expect( setAttributes ).toHaveBeenCalledWith( {
+ conditionalLogic: expect.objectContaining( {
+ groups: [
+ {
+ logicalOperator: 'all',
+ rules: [ { field: 'untitled-field', operator: 'is', value: '' } ],
+ },
+ ],
+ } ),
+ } );
+ } );
+
+ /**
+ * useSubjectFields excludes the field owning the panel, so the owner's id is the one the
+ * used-id list cannot see. It has to be passed in explicitly, or an unnamed sibling whose
+ * label slugifies to the same thing is handed the owner's id -- and PHP's duplicate guard
+ * then renames whichever parses second, repointing the rule or changing the owner's
+ * response key. The de-duplication itself is covered in use-subject-fields.test.jsx.
+ */
+ it( "feeds the panel's own field id into the uniqueness check", async () => {
+ mockEnsureFieldId.mockClear();
+ await setup( withRules( [ { field: 'name_1', operator: 'is', value: 'x' } ] ), {
+ ownFieldId: 'email',
+ } );
+
+ await userEvent.selectOptions( screen.getByLabelText( 'Field' ), 'clientId:c-colour' );
+
+ expect( mockEnsureFieldId ).toHaveBeenCalledWith(
+ expect.objectContaining( { clientId: 'c-colour' } ),
+ expect.arrayContaining( [ 'email' ] )
+ );
+ } );
+
+ it( 'keeps the existing id when the chosen field already has one', async () => {
+ const { setAttributes } = await setup(
+ withRules( [ { field: 'name_1', operator: 'is', value: 'x' } ] )
+ );
+
+ await userEvent.selectOptions( screen.getByLabelText( 'Field' ), 'budget_1' );
+
+ expect( setAttributes ).toHaveBeenCalledWith( {
+ conditionalLogic: expect.objectContaining( {
+ groups: [
+ {
+ logicalOperator: 'all',
+ rules: [ { field: 'budget_1', operator: 'equals', value: '' } ],
+ },
+ ],
+ } ),
+ } );
+ } );
+
+ it( 'removes a condition', async () => {
+ const { setAttributes } = await setup(
+ withRules( [
+ { field: 'name_1', operator: 'is', value: 'x' },
+ { field: 'budget_1', operator: 'gte', value: '5' },
+ ] )
+ );
+
+ await userEvent.click( screen.getByRole( 'button', { name: 'Remove condition 1' } ) );
+
+ expect( setAttributes ).toHaveBeenCalledWith( {
+ conditionalLogic: expect.objectContaining( {
+ groups: [
+ {
+ logicalOperator: 'all',
+ rules: [ { field: 'budget_1', operator: 'gte', value: '5' } ],
+ },
+ ],
+ } ),
+ } );
+ } );
+} );
diff --git a/projects/packages/forms/tests/js/blocks/shared/conditional-logic/register.test.js b/projects/packages/forms/tests/js/blocks/shared/conditional-logic/register.test.js
new file mode 100644
index 000000000000..a6a360f280f6
--- /dev/null
+++ b/projects/packages/forms/tests/js/blocks/shared/conditional-logic/register.test.js
@@ -0,0 +1,109 @@
+import { jest } from '@jest/globals';
+import { hasFilter, removeFilter } from '@wordpress/hooks';
+
+const mockHasFeatureFlag = jest.fn( () => true );
+
+await jest.unstable_mockModule( '@automattic/jetpack-shared-extension-utils', () => ( {
+ hasFeatureFlag: ( ...args ) => mockHasFeatureFlag( ...args ),
+} ) );
+
+/**
+ * A stand-in block registry.
+ *
+ * The lookup is built from the blocks themselves, so importing the real child-blocks module
+ * here would pull every block — and its editor dependencies — into this test just to answer a
+ * question about the guard. The stub keeps that question isolated: whether a block gets a
+ * panel follows from its own declaration, whatever the registry happens to contain. Coverage
+ * that every real field block declares a type lives in block-names.test.js, read from source.
+ */
+await jest.unstable_mockModule( '../../../../../src/blocks/contact-form/child-blocks.js', () => ( {
+ childBlocks: [
+ { name: 'field-text', conditional_logic: { type: 'string' } },
+ { name: 'field-radio', conditional_logic: { type: 'choice' } },
+ { name: 'field-checkbox', conditional_logic: { type: 'boolean' } },
+ // Field-prefixed but with no declaration: opts out of conditional logic.
+ { name: 'field-not-a-real-type' },
+ // jetpack/input imports the same shared settings and therefore carries the attribute,
+ // but it is an inner input, not a field, so it must not get a panel of its own.
+ { name: 'input', conditional_logic: { type: 'string' } },
+ { name: 'label' },
+ ],
+} ) );
+
+const { FEATURE_FLAG, FILTER_NAMESPACE, isConditionalLogicField, registerConditionalLogicFilter } =
+ await import( '../../../../../src/blocks/shared/conditional-logic/register.jsx' );
+
+describe( 'conditional logic registration', () => {
+ it.each( [ 'jetpack/field-text', 'jetpack/field-radio', 'jetpack/field-checkbox' ] )(
+ 'applies to %s',
+ name => {
+ expect( isConditionalLogicField( name ) ).toBe( true );
+ }
+ );
+
+ it.each( [
+ 'jetpack/input',
+ 'jetpack/input-image-option',
+ 'jetpack/contact-form',
+ 'jetpack/label',
+ 'jetpack/option',
+ 'jetpack/options',
+ 'jetpack/form-step',
+ 'jetpack/fieldset-image-options',
+ 'core/paragraph',
+ 'core/heading',
+ ] )( 'does not apply to %s', name => {
+ expect( isConditionalLogicField( name ) ).toBe( false );
+ } );
+
+ it( 'tolerates a missing or non-string block name', () => {
+ expect( isConditionalLogicField( undefined ) ).toBe( false );
+ expect( isConditionalLogicField( null ) ).toBe( false );
+ expect( isConditionalLogicField( '' ) ).toBe( false );
+ expect( isConditionalLogicField( 42 ) ).toBe( false );
+ } );
+
+ it( 'skips a field-prefixed block with no comparison behavior', () => {
+ expect( isConditionalLogicField( 'jetpack/field-not-a-real-type' ) ).toBe( false );
+ } );
+
+ // Regression: this module ships in both dist/blocks/editor.js and
+ // dist/form-editor/jetpack-form-editor.js, and the Forms editor screen loads both.
+ // addFilter does not de-duplicate by namespace, so an unguarded registration wrapped
+ // BlockEdit twice and rendered the panel twice.
+ describe( 'filter registration', () => {
+ afterEach( () => {
+ removeFilter( 'editor.BlockEdit', FILTER_NAMESPACE );
+ mockHasFeatureFlag.mockReturnValue( true );
+ } );
+
+ it( 'registers the BlockEdit filter when the feature flag is on', () => {
+ removeFilter( 'editor.BlockEdit', FILTER_NAMESPACE );
+
+ expect( registerConditionalLogicFilter() ).toBe( true );
+ expect( hasFilter( 'editor.BlockEdit', FILTER_NAMESPACE ) ).toBeTruthy();
+ expect( mockHasFeatureFlag ).toHaveBeenCalledWith( FEATURE_FLAG );
+ } );
+
+ // Regression: this module ships in both dist/blocks/editor.js and
+ // dist/form-editor/jetpack-form-editor.js, and the Forms editor screen loads both.
+ // addFilter does not de-duplicate by namespace, so an unguarded registration wrapped
+ // BlockEdit twice and rendered the panel twice.
+ it( 'declines to register a second time', () => {
+ removeFilter( 'editor.BlockEdit', FILTER_NAMESPACE );
+
+ expect( registerConditionalLogicFilter() ).toBe( true );
+ expect( registerConditionalLogicFilter() ).toBe( false );
+ expect( registerConditionalLogicFilter() ).toBe( false );
+ expect( hasFilter( 'editor.BlockEdit', FILTER_NAMESPACE ) ).toBeTruthy();
+ } );
+
+ it( 'does not register at all when the feature flag is off', () => {
+ removeFilter( 'editor.BlockEdit', FILTER_NAMESPACE );
+ mockHasFeatureFlag.mockReturnValue( false );
+
+ expect( registerConditionalLogicFilter() ).toBe( false );
+ expect( hasFilter( 'editor.BlockEdit', FILTER_NAMESPACE ) ).toBeFalsy();
+ } );
+ } );
+} );
diff --git a/projects/packages/forms/tests/js/blocks/shared/conditional-logic/summary.test.js b/projects/packages/forms/tests/js/blocks/shared/conditional-logic/summary.test.js
new file mode 100644
index 000000000000..bbdc446bcbd6
--- /dev/null
+++ b/projects/packages/forms/tests/js/blocks/shared/conditional-logic/summary.test.js
@@ -0,0 +1,78 @@
+import {
+ describeRule,
+ getActiveConditions,
+ getSummaryHeading,
+ getSummaryText,
+} from '../../../../../src/blocks/shared/conditional-logic/util/summary.js';
+
+const FIELDS = [
+ { clientId: 'c-phone', id: 'phone', label: 'Phone', typeKey: 'choice', options: [] },
+ { clientId: 'c-email', id: 'email', label: 'Email', typeKey: 'string', options: [] },
+];
+
+const group = ( rules, logicalOperator = 'all' ) => ( { logicalOperator, rules } );
+
+describe( 'getSummaryHeading', () => {
+ // Four separate strings rather than one assembled from fragments: a sentence built by
+ // concatenation cannot be reordered by a translator.
+ it.each( [
+ [ 'show', 'all', 'This field is shown only if:' ],
+ [ 'show', 'any', 'This field is shown if any of these are true:' ],
+ [ 'hide', 'all', 'This field is hidden only if:' ],
+ [ 'hide', 'any', 'This field is hidden if any of these are true:' ],
+ ] )( '%s + %s reads "%s"', ( action, logicalOperator, expected ) => {
+ expect( getSummaryHeading( { action }, { logicalOperator } ) ).toBe( expected );
+ } );
+} );
+
+describe( 'describeRule', () => {
+ it( 'reads as the sentence the author built', () => {
+ expect( describeRule( { field: 'phone', operator: 'is', value: 'iPhone' }, FIELDS[ 0 ] ) ).toBe(
+ 'Phone is “iPhone”'
+ );
+ } );
+
+ // `is empty` and friends compare against nothing, so there is nothing to quote after them.
+ it( 'leaves off the value for an operator that takes none', () => {
+ expect( describeRule( { field: 'email', operator: 'is_not_empty' }, FIELDS[ 1 ] ) ).toBe(
+ 'Email is not empty'
+ );
+ } );
+} );
+
+describe( 'getActiveConditions', () => {
+ // An incomplete rule is skipped by both evaluators, so listing it would describe behaviour
+ // the field does not have.
+ it( 'lists only the conditions that will be acted on', () => {
+ const active = getActiveConditions(
+ group( [
+ { field: 'phone', operator: 'is', value: 'iPhone' },
+ { field: 'email', operator: 'is', value: '' },
+ { field: 'gone', operator: 'is', value: 'x' },
+ ] ),
+ FIELDS
+ );
+
+ expect( active ).toHaveLength( 1 );
+ expect( active[ 0 ].subject.label ).toBe( 'Phone' );
+ } );
+} );
+
+describe( 'getSummaryText', () => {
+ it( 'says the same thing on one line, for the toolbar tooltip', () => {
+ expect(
+ getSummaryText(
+ { action: 'show' },
+ group( [
+ { field: 'phone', operator: 'is', value: 'iPhone' },
+ { field: 'email', operator: 'is_not_empty' },
+ ] ),
+ FIELDS
+ )
+ ).toBe( 'This field is shown only if: Phone is “iPhone”; Email is not empty' );
+ } );
+
+ it( 'is empty when nothing is active', () => {
+ expect( getSummaryText( { action: 'show' }, group( [] ), FIELDS ) ).toBe( '' );
+ } );
+} );
diff --git a/projects/packages/forms/tests/js/blocks/shared/conditional-logic/use-subject-fields.test.jsx b/projects/packages/forms/tests/js/blocks/shared/conditional-logic/use-subject-fields.test.jsx
new file mode 100644
index 000000000000..4b180e8455f1
--- /dev/null
+++ b/projects/packages/forms/tests/js/blocks/shared/conditional-logic/use-subject-fields.test.jsx
@@ -0,0 +1,221 @@
+import { describe, expect, it, jest, beforeEach } from '@jest/globals';
+import { renderHook } from '@testing-library/react';
+
+/**
+ * The real hooks, not the stubs the panel suite uses.
+ *
+ * panel.test.jsx mocks both of these so it can drive the rule builder without a block editor,
+ * which means the behaviour they actually implement -- which fields are offered as subjects,
+ * and what id a chosen one is given -- was never exercised. That id assignment is the part
+ * worth pinning: getting it wrong silently repoints a rule at the wrong field, or renames a
+ * field that may already have responses stored against its old id.
+ */
+
+const mockUpdateBlockAttributes = jest.fn();
+
+// A block-editor store standing in for one form. Keyed by client id, the way getBlock is.
+let blocks = {};
+let rootOf = {};
+
+await jest.unstable_mockModule( '@wordpress/data', () => ( {
+ useDispatch: () => ( { updateBlockAttributes: mockUpdateBlockAttributes } ),
+ useSelect: selector =>
+ selector( () => ( {
+ getBlock: clientId => blocks[ clientId ],
+ getBlockParentsByBlockName: ( clientId, name ) =>
+ 'jetpack/contact-form' === name && rootOf[ clientId ] ? [ rootOf[ clientId ] ] : [],
+ getBlockRootClientId: clientId => rootOf[ clientId ] || '',
+ } ) ),
+} ) );
+
+await jest.unstable_mockModule( '@wordpress/block-editor', () => ( {
+ store: 'core/block-editor',
+} ) );
+
+await jest.unstable_mockModule( '@wordpress/blocks', () => ( {
+ getBlockType: name => ( { title: name.replace( 'jetpack/field-', '' ) } ),
+} ) );
+
+/**
+ * A stand-in block registry.
+ *
+ * The hook resolves a block's comparison behaviour through block-types, which reads the real
+ * registry -- and that side-effect imports every block in the package. Stubbing the boundary
+ * keeps this file about which fields are offered, not about what the registry contains.
+ */
+await jest.unstable_mockModule( '../../../../../src/blocks/contact-form/child-blocks.js', () => ( {
+ childBlocks: [
+ { name: 'field-text', conditional_logic: { type: 'string' } },
+ { name: 'field-select', conditional_logic: { type: 'choice' } },
+ ],
+} ) );
+
+const { default: useSubjectFields, useEnsureFieldId } = await import(
+ '../../../../../src/blocks/shared/conditional-logic/hooks/use-subject-fields.js'
+);
+
+/**
+ * A field block, optionally carrying a label block and an explicit id.
+ *
+ * @param {string} clientId - Block client id.
+ * @param {object} [options] - Field options.
+ * @param {string} [options.label] - Text of the field's label block, if it has one.
+ * @param {string} [options.id] - Explicit field id, if the field carries one.
+ * @param {string} [options.name] - Block name; defaults to a text field.
+ * @return {object} A block instance shaped the way the store returns them.
+ */
+const field = ( clientId, { label, id, name = 'jetpack/field-text' } = {} ) => ( {
+ clientId,
+ name,
+ attributes: id ? { id } : {},
+ innerBlocks: label ? [ { name: 'jetpack/label', attributes: { label } } ] : [],
+} );
+
+const ensureFieldId = () => renderHook( () => useEnsureFieldId() ).result.current;
+const subjectsFor = clientId => renderHook( () => useSubjectFields( clientId ) ).result.current;
+
+beforeEach( () => {
+ mockUpdateBlockAttributes.mockClear();
+ blocks = {};
+ rootOf = {};
+} );
+
+describe( 'useEnsureFieldId', () => {
+ // Most fields carry no explicit id: the renderer derives one from the label at output
+ // time. A rule cannot reference a derived id safely, because editing the label would
+ // change it and the rule would quietly stop matching.
+ it( 'mints an id from the label for a field that has none', () => {
+ const assigned = ensureFieldId()( { clientId: 'c-1', id: '', label: 'Favorite Color' }, [] );
+
+ expect( assigned ).toBe( 'favorite-color' );
+ expect( mockUpdateBlockAttributes ).toHaveBeenCalledWith( 'c-1', { id: 'favorite-color' } );
+ } );
+
+ it( 'keeps an explicit id and writes nothing', () => {
+ const assigned = ensureFieldId()( { clientId: 'c-1', id: 'email_1', label: 'Email' }, [] );
+
+ expect( assigned ).toBe( 'email_1' );
+ expect( mockUpdateBlockAttributes ).not.toHaveBeenCalled();
+ } );
+
+ it( 'de-duplicates against ids already in the form', () => {
+ const assigned = ensureFieldId()( { clientId: 'c-1', id: '', label: 'Email' }, [ 'email' ] );
+
+ expect( assigned ).toBe( 'email-2' );
+ expect( mockUpdateBlockAttributes ).toHaveBeenCalledWith( 'c-1', { id: 'email-2' } );
+ } );
+
+ /**
+ * The collision that made this guard necessary.
+ *
+ * useSubjectFields excludes the field owning the panel, so the owner's id is the one the
+ * used-id list cannot see. Without it passed in, an unnamed "Email" subject chosen from a
+ * panel on a field already using `email` is handed `email` unchanged -- and PHP's
+ * duplicate guard then renames whichever parses second. Either the rule silently starts
+ * evaluating a different field, or the owner's response key changes underneath a form
+ * that may already have responses stored against it.
+ */
+ it( "does not reuse the panel's own field id", () => {
+ const assigned = ensureFieldId()( { clientId: 'c-1', id: '', label: 'Email' }, [
+ 'email',
+ 'email-2',
+ ] );
+
+ expect( assigned ).toBe( 'email-3' );
+ expect( assigned ).not.toBe( 'email' );
+ } );
+
+ it( 'falls back to a generic base when the label slugifies to nothing', () => {
+ const assigned = ensureFieldId()( { clientId: 'c-1', id: '', label: '!!!' }, [] );
+
+ expect( assigned ).toBe( 'field' );
+ } );
+
+ it( 'assigns nothing for a missing field', () => {
+ expect( ensureFieldId()( null, [] ) ).toBe( '' );
+ expect( mockUpdateBlockAttributes ).not.toHaveBeenCalled();
+ } );
+} );
+
+describe( 'useSubjectFields', () => {
+ it( 'lists sibling fields, excludes the panel’s own, and keeps id-less ones', () => {
+ blocks = {
+ form: {
+ clientId: 'form',
+ name: 'jetpack/contact-form',
+ innerBlocks: [
+ field( 'c-owner', { label: 'Owner', id: 'owner_1' } ),
+ field( 'c-name', { label: 'Name', id: 'name_1' } ),
+ field( 'c-colour', { label: 'Colour' } ),
+ ],
+ },
+ };
+ rootOf = { 'c-owner': 'form' };
+
+ const found = subjectsFor( 'c-owner' );
+
+ expect( found.map( entry => entry.clientId ) ).toEqual( [ 'c-name', 'c-colour' ] );
+ // Listed despite having no explicit id -- requiring one would hide nearly every field.
+ expect( found.find( entry => entry.clientId === 'c-colour' ).id ).toBe( '' );
+ expect( found.find( entry => entry.clientId === 'c-name' ).id ).toBe( 'name_1' );
+ } );
+
+ // A rule referencing a later step always compares against an empty value. The author
+ // should be able to see that rather than be silently prevented from writing it.
+ it( 'numbers the step each field sits in', () => {
+ blocks = {
+ form: {
+ clientId: 'form',
+ name: 'jetpack/contact-form',
+ innerBlocks: [
+ {
+ clientId: 'step-1',
+ name: 'jetpack/form-step',
+ innerBlocks: [ field( 'c-a', { label: 'A' } ) ],
+ },
+ {
+ clientId: 'step-2',
+ name: 'jetpack/form-step',
+ innerBlocks: [ field( 'c-b', { label: 'B' } ) ],
+ },
+ ],
+ },
+ };
+ rootOf = { 'c-owner': 'form' };
+
+ const bySteps = Object.fromEntries(
+ subjectsFor( 'c-owner' ).map( entry => [ entry.clientId, entry.step ] )
+ );
+
+ expect( bySteps ).toEqual( { 'c-a': 1, 'c-b': 2 } );
+ } );
+
+ it( 'falls back to the label, then the id, then a placeholder', () => {
+ blocks = {
+ form: {
+ clientId: 'form',
+ name: 'jetpack/contact-form',
+ innerBlocks: [
+ field( 'c-labelled', { label: 'Budget' } ),
+ field( 'c-id-only', { id: 'total_1' } ),
+ field( 'c-bare' ),
+ ],
+ },
+ };
+ rootOf = { 'c-owner': 'form' };
+
+ const labels = Object.fromEntries(
+ subjectsFor( 'c-owner' ).map( entry => [ entry.clientId, entry.label ] )
+ );
+
+ expect( labels ).toEqual( {
+ 'c-labelled': 'Budget',
+ 'c-id-only': 'total_1',
+ 'c-bare': 'Untitled field',
+ } );
+ } );
+
+ it( 'offers nothing when the field is not inside a form', () => {
+ expect( subjectsFor( 'c-orphan' ) ).toEqual( [] );
+ } );
+} );
diff --git a/projects/packages/forms/tests/js/blocks/shared/conditional-logic/with-conditional-logic.test.jsx b/projects/packages/forms/tests/js/blocks/shared/conditional-logic/with-conditional-logic.test.jsx
new file mode 100644
index 000000000000..1ad12c04d706
--- /dev/null
+++ b/projects/packages/forms/tests/js/blocks/shared/conditional-logic/with-conditional-logic.test.jsx
@@ -0,0 +1,106 @@
+import { describe, expect, it, jest } from '@jest/globals';
+import { render, screen } from '@testing-library/react';
+
+/**
+ * The panel is loaded through a lazy boundary, so that a site with the feature off never
+ * fetches, parses or runs any of it.
+ *
+ * That boundary is invisible to every other test here: panel.test.jsx imports the panel
+ * directly, and register.test.js only inspects the guard. Nothing rendered the wrapped
+ * BlockEdit, so a broken `lazy()`/`Suspense` pair would have shown up as an empty inspector in
+ * the browser and a green suite -- which is how two earlier defects on this feature survived.
+ */
+
+await jest.unstable_mockModule( '@automattic/jetpack-shared-extension-utils', () => ( {
+ hasFeatureFlag: () => true,
+} ) );
+
+await jest.unstable_mockModule( '../../../../../src/blocks/contact-form/child-blocks.js', () => ( {
+ childBlocks: [ { name: 'field-text', conditional_logic: { type: 'string' } } ],
+} ) );
+
+const mockToggleBlockHighlight = jest.fn();
+
+await jest.unstable_mockModule( '@wordpress/block-editor', () => ( {
+ InspectorControls: ( { children } ) =>
{ children }
,
+ BlockControls: ( { children } ) =>
{ children }
,
+ store: 'core/block-editor',
+} ) );
+
+// Only useDispatch is replaced. Something in the panel's import graph pulls the real store
+// helpers from this module, so a mock that omits them fails at import rather than in a test --
+// and one that imports the module from inside its own factory recurses until V8 gives up.
+// Loading it first, then registering the mock, avoids both.
+const actualData = await import( '@wordpress/data' );
+
+await jest.unstable_mockModule( '@wordpress/data', () => ( {
+ ...actualData,
+ useDispatch: () => ( { toggleBlockHighlight: mockToggleBlockHighlight } ),
+} ) );
+
+await jest.unstable_mockModule(
+ '../../../../../src/blocks/shared/conditional-logic/hooks/use-subject-fields.js',
+ () => ( {
+ __esModule: true,
+ default: () => [],
+ useEnsureFieldId: () => () => 'field_1',
+ } )
+);
+
+const { withConditionalLogic } = await import(
+ '../../../../../src/blocks/shared/conditional-logic/register.jsx'
+);
+
+// Transform the panel and its dependency tree up front. Without this the first render pays
+// for compiling the whole subtree, which overruns findBy's default timeout once the full
+// suite saturates the workers -- a failure about machine load, not about the code.
+await import( '../../../../../src/blocks/shared/conditional-logic/components/panel.jsx' );
+
+const noop = () => {};
+const BlockEdit = ( { name } ) =>
edit: { name }
;
+const WrappedBlockEdit = withConditionalLogic( BlockEdit );
+
+const renderBlock = ( name, isSelected = true ) =>
+ render(
+
+ );
+
+describe( 'withConditionalLogic', () => {
+ it( 'resolves the lazily loaded panel and mounts it on a field block', async () => {
+ renderBlock( 'jetpack/field-text' );
+
+ // The wrapped editor is available immediately; the panel arrives only once the lazy
+ // boundary resolves, so this has to be awaited rather than queried synchronously.
+ expect( screen.getByText( 'edit: jetpack/field-text' ) ).toBeInTheDocument();
+ await expect(
+ screen.findByRole( 'button', { name: 'Conditional logic' } )
+ ).resolves.toBeInTheDocument();
+ } );
+
+ /**
+ * The filter wraps every field block, but the inspector only shows the selected one.
+ * Mounting the panel on the rest made its form-tree walk run per field on every
+ * block-editor store change.
+ */
+ it( 'does not mount the panel on a field block that is not selected', () => {
+ renderBlock( 'jetpack/field-text', false );
+
+ expect( screen.getByText( 'edit: jetpack/field-text' ) ).toBeInTheDocument();
+ expect( screen.queryByRole( 'button', { name: 'Conditional logic' } ) ).not.toBeInTheDocument();
+ } );
+
+ it( 'renders a non-field block untouched, without loading the panel', () => {
+ renderBlock( 'core/paragraph' );
+
+ expect( screen.getByText( 'edit: core/paragraph' ) ).toBeInTheDocument();
+ // Nothing to await: the lazy import is never reached for a block that has no
+ // conditional-logic support.
+ expect( screen.queryByRole( 'button', { name: 'Conditional logic' } ) ).not.toBeInTheDocument();
+ } );
+} );
diff --git a/projects/packages/forms/tests/js/modules/form/conditional-visibility.test.js b/projects/packages/forms/tests/js/modules/form/conditional-visibility.test.js
new file mode 100644
index 000000000000..6c16eaf141bd
--- /dev/null
+++ b/projects/packages/forms/tests/js/modules/form/conditional-visibility.test.js
@@ -0,0 +1,225 @@
+import {
+ clearVisibilityMemo,
+ isFieldHiddenByLogic,
+ resolveFormVisibility,
+} from '../../../../src/modules/form/conditional-visibility.js';
+
+const showWhen = ( field, value ) => ( {
+ enabled: true,
+ action: 'show',
+ logicalOperator: 'all',
+ groups: [ { logicalOperator: 'all', rules: [ { field, operator: 'is', value } ] } ],
+} );
+
+/**
+ * Build the interactivity context the form block emits.
+ *
+ * @param {object} options - Context options.
+ * @param {string} options.formHash - The form's hash.
+ * @param {object} options.types - Map of field id to shortcode type.
+ * @param {object} options.logic - Map of field id to conditional-logic config.
+ * @param {object} options.values - Map of field id to current value.
+ * @return {object} An interactivity context.
+ */
+const context = ( { formHash, types, logic, values } ) => ( {
+ formHash,
+ conditionalLogic: { types, logic },
+ fields: Object.fromEntries(
+ Object.entries( values ).map( ( [ id, value ] ) => [ id, { value } ] )
+ ),
+} );
+
+describe( 'resolveFormVisibility', () => {
+ beforeEach( clearVisibilityMemo );
+
+ it( 'returns null when no field has conditional logic', () => {
+ expect(
+ resolveFormVisibility( {
+ formHash: 'a',
+ fields: { one: { value: '' } },
+ } )
+ ).toBeNull();
+ expect(
+ resolveFormVisibility(
+ context( {
+ formHash: 'a',
+ types: { one: 'text' },
+ logic: {},
+ values: { one: '' },
+ } )
+ )
+ ).toBeNull();
+ } );
+
+ it( 'hides a field whose condition is not met', () => {
+ const visible = resolveFormVisibility(
+ context( {
+ formHash: 'a',
+ types: { trigger: 'text', dependent: 'text' },
+ logic: { dependent: showWhen( 'trigger', 'Other' ) },
+ values: { trigger: 'Something else', dependent: '' },
+ } )
+ );
+
+ expect( visible.dependent ).toBe( false );
+ expect( visible.trigger ).toBe( true );
+ } );
+
+ it( 'shows it once the condition is met', () => {
+ const visible = resolveFormVisibility(
+ context( {
+ formHash: 'a',
+ types: { trigger: 'text', dependent: 'text' },
+ logic: { dependent: showWhen( 'trigger', 'Other' ) },
+ values: { trigger: 'Other', dependent: '' },
+ } )
+ );
+
+ expect( visible.dependent ).toBe( true );
+ } );
+
+ // Regression: the memo used to be a single module-level slot keyed only on the value
+ // signature, so a second form on the page with a matching signature was handed the first
+ // form's map and showed or hid the wrong fields. Field ids derive from labels, so two
+ // forms sharing a "trigger"/"dependent" pair is entirely ordinary.
+ it( "does not leak one form's visibility to another with the same value signature", () => {
+ const shared = {
+ types: { trigger: 'text', dependent: 'text' },
+ values: { trigger: 'Other', dependent: '' },
+ };
+
+ const first = resolveFormVisibility(
+ context( {
+ ...shared,
+ formHash: 'form-one',
+ logic: { dependent: showWhen( 'trigger', 'Other' ) },
+ } )
+ );
+
+ // Same field ids and identical values, but the opposite rule.
+ const second = resolveFormVisibility(
+ context( {
+ ...shared,
+ formHash: 'form-two',
+ logic: { dependent: showWhen( 'trigger', 'Something else' ) },
+ } )
+ );
+
+ expect( first.dependent ).toBe( true );
+ expect( second.dependent ).toBe( false );
+ } );
+
+ it( 'memoizes per form rather than globally', () => {
+ const build = ( formHash, triggerValue ) =>
+ context( {
+ formHash,
+ types: { trigger: 'text', dependent: 'text' },
+ logic: { dependent: showWhen( 'trigger', 'Other' ) },
+ values: { trigger: triggerValue, dependent: '' },
+ } );
+
+ const a1 = resolveFormVisibility( build( 'form-one', 'Other' ) );
+ const b1 = resolveFormVisibility( build( 'form-two', 'Other' ) );
+ const a2 = resolveFormVisibility( build( 'form-one', 'Other' ) );
+
+ // Each form keeps its own cached map, and a repeat call reuses it.
+ expect( a2 ).toBe( a1 );
+ expect( b1 ).not.toBe( a1 );
+ } );
+
+ it( 're-resolves when a value changes', () => {
+ const build = triggerValue =>
+ context( {
+ formHash: 'form-one',
+ types: { trigger: 'text', dependent: 'text' },
+ logic: { dependent: showWhen( 'trigger', 'Other' ) },
+ values: { trigger: triggerValue, dependent: '' },
+ } );
+
+ expect( resolveFormVisibility( build( 'Other' ) ).dependent ).toBe( true );
+ expect( resolveFormVisibility( build( 'Nope' ) ).dependent ).toBe( false );
+ } );
+
+ // The context carries shortcode types, which is what both evaluators take.
+ it( 'compares a multiple-choice field by membership, not substring', () => {
+ const build = values =>
+ context( {
+ formHash: 'form-choice',
+ types: { colours: 'checkbox-multiple', dependent: 'text' },
+ logic: {
+ dependent: {
+ enabled: true,
+ action: 'show',
+ logicalOperator: 'all',
+ groups: [
+ {
+ logicalOperator: 'all',
+ rules: [ { field: 'colours', operator: 'contains', value: 'Blue' } ],
+ },
+ ],
+ },
+ },
+ values,
+ } );
+
+ expect(
+ resolveFormVisibility( build( { colours: [ 'Blueberry' ], dependent: '' } ) ).dependent
+ ).toBe( false );
+
+ clearVisibilityMemo();
+
+ expect(
+ resolveFormVisibility( build( { colours: [ 'Blue' ], dependent: '' } ) ).dependent
+ ).toBe( true );
+ } );
+
+ it( 'falls back to a shared key when the form has no hash', () => {
+ const visible = resolveFormVisibility(
+ context( {
+ formHash: undefined,
+ types: { trigger: 'text', dependent: 'text' },
+ logic: { dependent: showWhen( 'trigger', 'Other' ) },
+ values: { trigger: 'Other', dependent: '' },
+ } )
+ );
+
+ expect( visible.dependent ).toBe( true );
+ } );
+
+ describe( 'isFieldHiddenByLogic', () => {
+ const build = triggerValue =>
+ context( {
+ formHash: 'form-one',
+ types: { trigger: 'text', dependent: 'text' },
+ logic: { dependent: showWhen( 'trigger', 'Other' ) },
+ values: { trigger: triggerValue, dependent: '' },
+ } );
+
+ // The visitor cannot see or fill a hidden field, so client-side validation must not
+ // count an error against it — otherwise Submit silently stops working.
+ it( 'reports a field hidden by its condition', () => {
+ expect( isFieldHiddenByLogic( build( 'Nope' ), 'dependent' ) ).toBe( true );
+ } );
+
+ it( 'reports a shown field as not hidden', () => {
+ expect( isFieldHiddenByLogic( build( 'Other' ), 'dependent' ) ).toBe( false );
+ } );
+
+ it( 'reports an unconditional field as not hidden', () => {
+ expect( isFieldHiddenByLogic( build( 'Nope' ), 'trigger' ) ).toBe( false );
+ } );
+
+ it( 'reports nothing hidden when the form has no conditional logic', () => {
+ const plain = {
+ formHash: 'plain',
+ fields: { one: { value: '' } },
+ };
+
+ expect( isFieldHiddenByLogic( plain, 'one' ) ).toBe( false );
+ } );
+
+ it( 'reports an unknown field as not hidden', () => {
+ expect( isFieldHiddenByLogic( build( 'Nope' ), 'no-such-field' ) ).toBe( false );
+ } );
+ } );
+} );
diff --git a/projects/packages/forms/tests/php/contact-form/Conditional_Logic_Behaviour_Test.php b/projects/packages/forms/tests/php/contact-form/Conditional_Logic_Behaviour_Test.php
new file mode 100644
index 000000000000..6385fc677fa7
--- /dev/null
+++ b/projects/packages/forms/tests/php/contact-form/Conditional_Logic_Behaviour_Test.php
@@ -0,0 +1,112 @@
+>
+ */
+ private static function load_cases(): array {
+ $path = __DIR__ . '/../../fixtures/conditional-logic-behaviour.json';
+ $data = json_decode( (string) file_get_contents( $path ), true );
+
+ if ( ! is_array( $data ) || ! isset( $data['cases'] ) || ! is_array( $data['cases'] ) ) {
+ throw new \RuntimeException( 'conditional-logic-behaviour.json is missing its cases array.' );
+ }
+
+ return $data['cases'];
+ }
+
+ /**
+ * The shared case table.
+ *
+ * @return array}>
+ */
+ public static function behaviour_cases(): array {
+ $cases = array();
+ foreach ( self::load_cases() as $case ) {
+ $cases[ $case['name'] ] = array( $case );
+ }
+
+ return $cases;
+ }
+
+ /**
+ * @dataProvider behaviour_cases
+ *
+ * @param array $case One row of the shared table.
+ */
+ #[\PHPUnit\Framework\Attributes\DataProvider( 'behaviour_cases' )]
+ public function test_matches_the_shared_behaviour_table( array $case ) {
+ $logic = array(
+ 'enabled' => true,
+ 'action' => 'show',
+ 'logicalOperator' => 'all',
+ 'groups' => array(
+ array(
+ 'logicalOperator' => 'all',
+ 'rules' => array(
+ array(
+ 'field' => 'subject',
+ 'operator' => $case['operator'],
+ 'value' => $case['value'],
+ ),
+ ),
+ ),
+ ),
+ );
+
+ $formats = isset( $case['format'] ) ? array( 'subject' => $case['format'] ) : array();
+
+ $visible = Conditional_Logic::evaluate(
+ $logic,
+ array( 'subject' => $case['type'] ),
+ array( 'subject' => $case['actual'] ),
+ $formats
+ );
+
+ $this->assertSame(
+ $case['visible'],
+ $visible,
+ $case['why'] ?? 'Behaviour must match the JavaScript evaluator.'
+ );
+ }
+
+ /**
+ * The table is only worth anything if both sides actually read it.
+ */
+ public function test_the_shared_table_covers_every_comparison_family() {
+ $types = array_unique( array_column( self::load_cases(), 'type' ) );
+
+ foreach ( array( 'date', 'time', 'number', 'consent', 'checkbox', 'text', 'select' ) as $type ) {
+ $this->assertContains( $type, $types, "The shared table lost its $type cases." );
+ }
+ }
+}
diff --git a/projects/packages/forms/tests/php/contact-form/Conditional_Logic_Feature_Flag_Test.php b/projects/packages/forms/tests/php/contact-form/Conditional_Logic_Feature_Flag_Test.php
new file mode 100644
index 000000000000..1a7763203d46
--- /dev/null
+++ b/projects/packages/forms/tests/php/contact-form/Conditional_Logic_Feature_Flag_Test.php
@@ -0,0 +1,202 @@
+ 'cf-flag-test' ) );
+
+ $trigger = new Contact_Form_Field(
+ array(
+ 'id' => 'trigger',
+ 'type' => 'text',
+ 'label' => 'Trigger',
+ ),
+ '',
+ $form
+ );
+
+ $dependent = new Contact_Form_Field(
+ array(
+ 'id' => 'dependent',
+ 'type' => 'text',
+ 'label' => 'Dependent',
+ 'required' => '1',
+ 'conditionallogic' => array(
+ 'enabled' => true,
+ 'action' => 'show',
+ 'logicalOperator' => 'all',
+ 'groups' => array(
+ array(
+ 'logicalOperator' => 'all',
+ 'rules' => array(
+ array(
+ 'field' => 'trigger',
+ 'operator' => 'is',
+ 'value' => 'Other',
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ '',
+ $form
+ );
+
+ $_POST['trigger'] = 'Something else';
+ $_POST['dependent'] = '';
+
+ $trigger->value = 'Something else';
+ $dependent->value = '';
+
+ $form->fields = array(
+ 'trigger' => $trigger,
+ 'dependent' => $dependent,
+ );
+
+ return $form;
+ }
+
+ /**
+ * The flag is registered with the shared package, so it is discoverable through
+ * `Feature_Flags::all()` and carries the metadata that says who owns it.
+ */
+ public function test_the_flag_is_registered_with_the_feature_flags_package() {
+ Jetpack_Forms::register_feature_flags();
+
+ $definition = Feature_Flags::get( Jetpack_Forms::CONDITIONAL_LOGIC_FLAG );
+
+ $this->assertNotNull( $definition, 'The flag must be registered, not merely filtered.' );
+ $this->assertFalse( $definition['default'], 'It ships disabled.' );
+ $this->assertSame( 'jetpack-forms', $definition['owner'] );
+ $this->assertNotSame( '', $definition['description'] );
+ }
+
+ /**
+ * The generic package filter controls it too, not just the per-flag variant, so a policy
+ * layer can switch many flags from one place.
+ */
+ public function test_the_generic_package_filter_turns_the_feature_on() {
+ Jetpack_Forms::register_feature_flags();
+
+ $callback = static function ( $enabled, $flag_name ) {
+ return Jetpack_Forms::CONDITIONAL_LOGIC_FLAG === $flag_name ? true : $enabled;
+ };
+ add_filter( 'jetpack_feature_flag_enabled', $callback, 10, 2 );
+
+ $this->assertTrue( Jetpack_Forms::is_conditional_logic_enabled() );
+
+ remove_filter( 'jetpack_feature_flag_enabled', $callback, 10 );
+ }
+
+ public function test_the_feature_is_off_by_default() {
+ $this->assertFalse(
+ Jetpack_Forms::is_conditional_logic_enabled(),
+ 'Conditional logic must stay off until the flag is explicitly enabled.'
+ );
+ }
+
+ public function test_the_filter_turns_the_feature_on() {
+ add_filter( 'jetpack_feature_flag_enabled_forms-conditional-logic', '__return_true' );
+
+ $this->assertTrue( Jetpack_Forms::is_conditional_logic_enabled() );
+ }
+
+ public function test_no_front_end_context_is_emitted_when_disabled() {
+ $form = $this->build_form();
+
+ $this->assertSame(
+ array(),
+ $form->get_conditional_logic_context(),
+ 'A disabled feature must add nothing to the page, even on a form that has conditions.'
+ );
+ }
+
+ public function test_every_field_resolves_visible_when_disabled() {
+ $form = $this->build_form();
+
+ $this->assertSame(
+ array(),
+ $form->get_resolved_field_visibility(),
+ 'With the feature off no field is hidden, so the map is empty and callers treat every field as visible.'
+ );
+ }
+
+ /**
+ * The mirror of Conditional_Logic_Validation_Test: with the feature off the condition is
+ * ignored entirely, so the required field is enforced like any other.
+ */
+ public function test_conditions_are_ignored_during_validation_when_disabled() {
+ $form = $this->build_form();
+
+ $form->validate();
+
+ $this->assertTrue(
+ $form->has_errors(),
+ 'With the feature off the field is not hidden, so its required rule still applies.'
+ );
+ }
+
+ public function test_conditions_are_ignored_during_storage_when_disabled() {
+ $form = $this->build_form();
+
+ $feedback = Feedback::from_submission(
+ array(
+ 'trigger' => 'Something else',
+ 'dependent' => 'stored anyway',
+ ),
+ $form
+ );
+
+ $field = $feedback->get_field_by_form_field_id( 'dependent' );
+
+ $this->assertNotNull(
+ $field,
+ 'With the feature off nothing is stripped from the response.'
+ );
+ $this->assertSame( 'stored anyway', $field->get_value() );
+ }
+
+ public function test_the_same_form_hides_the_field_once_the_flag_is_on() {
+ add_filter( 'jetpack_feature_flag_enabled_forms-conditional-logic', '__return_true' );
+
+ $form = $this->build_form();
+
+ $this->assertFalse(
+ $form->get_resolved_field_visibility()['dependent'],
+ 'The flag is the only difference between this and the disabled case.'
+ );
+ }
+}
diff --git a/projects/packages/forms/tests/php/contact-form/Conditional_Logic_Initial_Render_Test.php b/projects/packages/forms/tests/php/contact-form/Conditional_Logic_Initial_Render_Test.php
new file mode 100644
index 000000000000..91b54806cb89
--- /dev/null
+++ b/projects/packages/forms/tests/php/contact-form/Conditional_Logic_Initial_Render_Test.php
@@ -0,0 +1,269 @@
+ true,
+ 'action' => 'show',
+ 'logicalOperator' => 'all',
+ 'groups' => array(
+ array(
+ 'logicalOperator' => 'all',
+ 'rules' => array(
+ array(
+ 'field' => 'trigger',
+ 'operator' => 'is',
+ 'value' => 'Other',
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+
+ /**
+ * Build a two-field form and return the rendered body.
+ *
+ * @return string
+ */
+ private function render_body(): string {
+ $form = new Contact_Form( array( 'id' => 'cl-render' ) );
+
+ $trigger = new Contact_Form_Field(
+ array(
+ 'id' => 'trigger',
+ 'type' => 'text',
+ 'label' => 'Trigger',
+ ),
+ '',
+ $form
+ );
+
+ $dependent = new Contact_Form_Field(
+ array(
+ 'id' => 'dependent',
+ 'type' => 'text',
+ 'label' => 'Dependent',
+ 'conditionallogic' => $this->logic(),
+ ),
+ '',
+ $form
+ );
+
+ $form->fields = array(
+ 'trigger' => $trigger,
+ 'dependent' => $dependent,
+ );
+
+ $form->body = $trigger->render() . $dependent->render();
+
+ $form->apply_initial_field_visibility();
+
+ return (string) $form->body;
+ }
+
+ /**
+ * Extract the wrapper markup for one field.
+ *
+ * @param string $body Rendered form body.
+ * @param string $field_id Field id.
+ * @return string
+ */
+ private function wrapper_for( string $body, string $field_id ): string {
+ $processor = new \WP_HTML_Tag_Processor( $body );
+
+ while ( $processor->next_tag( array( 'tag_name' => 'DIV' ) ) ) {
+ if ( $field_id === $processor->get_attribute( 'data-jp-visibility-root' ) ) {
+ return (string) $processor->get_attribute( 'class' );
+ }
+ }
+
+ return '';
+ }
+
+ public function test_a_hidden_field_ships_hidden() {
+ $body = $this->render_body();
+
+ $this->assertStringContainsString(
+ 'jetpack-field--conditionally-hidden',
+ $this->wrapper_for( $body, 'dependent' ),
+ 'A field whose condition is unmet must arrive hidden, not flash into view.'
+ );
+ }
+
+ public function test_an_unconditional_field_is_untouched() {
+ $body = $this->render_body();
+
+ $this->assertStringNotContainsString(
+ 'jetpack-field--conditionally-hidden',
+ $this->wrapper_for( $body, 'trigger' ),
+ 'A field with no conditions must never be marked hidden.'
+ );
+ }
+
+ /**
+ * Fields can be prefilled from the query string, e.g. `?trigger=Other`. The initial
+ * visibility has to take that into account: resolving against an empty form would hide a
+ * field the visitor can already see is satisfied, producing the opposite flash.
+ */
+ public function test_a_query_parameter_prefill_can_reveal_the_field() {
+ $_GET['trigger'] = 'Other';
+
+ $body = $this->render_body();
+
+ $this->assertStringNotContainsString(
+ 'jetpack-field--conditionally-hidden',
+ $this->wrapper_for( $body, 'dependent' ),
+ 'A query-parameter prefill satisfying the rule must render the field visible.'
+ );
+ }
+
+ public function test_a_query_parameter_that_does_not_match_keeps_it_hidden() {
+ $_GET['trigger'] = 'Something else';
+
+ $body = $this->render_body();
+
+ $this->assertStringContainsString(
+ 'jetpack-field--conditionally-hidden',
+ $this->wrapper_for( $body, 'dependent' )
+ );
+ }
+
+ public function test_a_submitted_value_takes_precedence_over_the_query_string() {
+ $_GET['trigger'] = 'Other';
+ $_POST['trigger'] = 'Something else';
+
+ $body = $this->render_body();
+
+ $this->assertStringContainsString(
+ 'jetpack-field--conditionally-hidden',
+ $this->wrapper_for( $body, 'dependent' ),
+ 'A re-rendered submission resolves against what was submitted, not the query string.'
+ );
+ }
+
+ /**
+ * The transition is scoped to [data-jp-conditional] so only fields that actually carry a
+ * condition animate; everything else renders with no animation cost at all.
+ */
+ public function test_only_conditional_fields_carry_the_animation_marker() {
+ $body = $this->render_body();
+
+ $processor = new \WP_HTML_Tag_Processor( $body );
+ $marked = array();
+
+ while ( $processor->next_tag( array( 'tag_name' => 'DIV' ) ) ) {
+ $field_id = $processor->get_attribute( 'data-jp-field-id' );
+
+ if ( null !== $field_id && null !== $processor->get_attribute( 'data-jp-conditional' ) ) {
+ $marked[] = $field_id;
+ }
+ }
+
+ $this->assertSame( array( 'dependent' ), $marked );
+ }
+
+ public function test_nothing_is_marked_when_the_feature_is_off() {
+ remove_filter( 'jetpack_feature_flag_enabled_forms-conditional-logic', '__return_true' );
+
+ $body = $this->render_body();
+
+ $this->assertStringNotContainsString(
+ 'jetpack-field--conditionally-hidden',
+ $this->wrapper_for( $body, 'dependent' )
+ );
+ }
+
+ /**
+ * An inset label puts the width class on an outer wrapper, so that wrapper is what holds
+ * the field's slot in the row. Hiding the inner div instead left the wrapper in place and
+ * the row kept a hole where the field had been.
+ */
+ public function test_an_inset_label_field_hides_the_wrapper_that_holds_the_row_slot() {
+ // The inset label comes from the form's block style, not a style attribute.
+ $form = new Contact_Form(
+ array(
+ 'id' => 'cl-inset',
+ 'className' => 'is-style-outlined',
+ )
+ );
+
+ $trigger = new Contact_Form_Field(
+ array(
+ 'id' => 'trigger',
+ 'type' => 'text',
+ 'label' => 'Trigger',
+ ),
+ '',
+ $form
+ );
+
+ $dependent = new Contact_Form_Field(
+ array(
+ 'id' => 'dependent',
+ 'type' => 'text',
+ 'label' => 'Dependent',
+ 'width' => '50',
+ 'conditionallogic' => $this->logic(),
+ ),
+ '',
+ $form
+ );
+
+ $form->fields = array(
+ 'trigger' => $trigger,
+ 'dependent' => $dependent,
+ );
+ $form->body = $trigger->render() . $dependent->render();
+
+ $form->apply_initial_field_visibility();
+
+ $classes = $this->wrapper_for( (string) $form->body, 'dependent' );
+
+ $this->assertStringContainsString(
+ 'jetpack-field--conditionally-hidden',
+ $classes,
+ 'The hidden field must ship hidden.'
+ );
+ $this->assertStringContainsString(
+ 'contact-form__inset-label-wrap',
+ $classes,
+ 'The element hidden must be the wrapper carrying the width, not the inner field.'
+ );
+ }
+}
diff --git a/projects/packages/forms/tests/php/contact-form/Conditional_Logic_Parity_Test.php b/projects/packages/forms/tests/php/contact-form/Conditional_Logic_Parity_Test.php
new file mode 100644
index 000000000000..168e4125092a
--- /dev/null
+++ b/projects/packages/forms/tests/php/contact-form/Conditional_Logic_Parity_Test.php
@@ -0,0 +1,117 @@
+field_types_path();
+ $this->assertFileExists( $path, 'field-types.ts moved; update this test to match.' );
+
+ $source = file_get_contents( $path );
+ $this->assertNotEmpty( $source, 'field-types.ts is empty.' );
+
+ return $source;
+ }
+
+ /**
+ * The PHP operator constants must match the TypeScript OPERATORS values exactly.
+ *
+ * Operator strings are persisted in post content, so a rename on one side silently stops
+ * matching rules saved by the other.
+ */
+ public function test_php_operator_constants_match_the_typescript_source() {
+ $source = $this->field_types_source();
+
+ $matched = preg_match( '/export const OPERATORS = \{(.*?)\} as const;/s', $source, $block );
+ $this->assertSame( 1, $matched, 'Could not locate the OPERATORS object in field-types.ts.' );
+
+ preg_match_all( "/^\s*[A-Z_]+:\s*'([a-z_]+)',/m", $block[1], $matches );
+ $ts_operators = $matches[1];
+ $this->assertNotEmpty( $ts_operators, 'No operators parsed from field-types.ts.' );
+
+ $php_operators = array();
+ foreach ( ( new ReflectionClass( Conditional_Logic::class ) )->getConstants() as $name => $value ) {
+ if ( 0 === strpos( $name, 'OP_' ) ) {
+ $php_operators[] = $value;
+ }
+ }
+
+ sort( $ts_operators );
+ sort( $php_operators );
+
+ $this->assertSame(
+ $ts_operators,
+ $php_operators,
+ 'Operator drift between field-types.ts and Conditional_Logic.'
+ );
+ }
+
+ /**
+ * Both sides must agree on how a shortcode field type compares.
+ *
+ * The editor keys off block names and the runtime keys off shortcode types; this table is
+ * where the two meet, so it has to be identical in both languages.
+ */
+ public function test_php_field_type_table_matches_the_typescript_source() {
+ $source = $this->field_types_source();
+
+ $matched = preg_match(
+ '/export const TYPE_KEY_BY_FIELD_TYPE: Record< string, TypeKey > = \{(.*?)\n\};/s',
+ $source,
+ $block
+ );
+ $this->assertSame( 1, $matched, 'Could not locate TYPE_KEY_BY_FIELD_TYPE in field-types.ts.' );
+
+ preg_match_all( "/^\s*'?([a-z-]+)'?:\s*'([a-z]+)',/m", $block[1], $matches, PREG_SET_ORDER );
+ $this->assertNotEmpty( $matches, 'No field type entries parsed from field-types.ts.' );
+
+ $ts_table = array();
+ foreach ( $matches as $entry ) {
+ $ts_table[ $entry[1] ] = $entry[2];
+ }
+
+ $php_table = Conditional_Logic::TYPE_KEY_BY_FIELD_TYPE;
+
+ ksort( $ts_table );
+ ksort( $php_table );
+
+ $this->assertSame(
+ $ts_table,
+ $php_table,
+ 'Field type mapping drift between field-types.ts and Conditional_Logic.'
+ );
+ }
+}
diff --git a/projects/packages/forms/tests/php/contact-form/Conditional_Logic_Required_Field_Test.php b/projects/packages/forms/tests/php/contact-form/Conditional_Logic_Required_Field_Test.php
new file mode 100644
index 000000000000..964ec308425c
--- /dev/null
+++ b/projects/packages/forms/tests/php/contact-form/Conditional_Logic_Required_Field_Test.php
@@ -0,0 +1,169 @@
+add_shortcode();
+ }
+ }
+
+ protected function tear_down() {
+ remove_filter( 'jetpack_feature_flag_enabled_forms-conditional-logic', '__return_true' );
+ Contact_Form::reset_seen_refs();
+ unset( $_POST );
+ parent::tear_down();
+ }
+
+ /**
+ * Parse a form whose required second field is conditional on the first.
+ *
+ * Goes through do_shortcode(), so parse_contact_field() runs for real and decides for
+ * itself whether to defer the dependent field's validation.
+ *
+ * @param string $trigger_value Submitted value for the trigger field.
+ * @param string $dependent_value Submitted value for the dependent field.
+ *
+ * @return Contact_Form
+ */
+ private function parse_form( $trigger_value, $dependent_value ): Contact_Form {
+ $_POST['trigger'] = $trigger_value;
+ $_POST['dependent'] = $dependent_value;
+
+ $logic = array(
+ 'enabled' => true,
+ 'action' => 'show',
+ 'logicalOperator' => 'all',
+ 'groups' => array(
+ array(
+ 'logicalOperator' => 'all',
+ 'rules' => array(
+ array(
+ 'field' => 'trigger',
+ 'operator' => 'is',
+ 'value' => 'Other',
+ ),
+ ),
+ ),
+ ),
+ );
+
+ // Serialized exactly as block_attributes_to_shortcode_attributes() does, so this pins
+ // the real wire format: quotes as entities, and brackets too, since WordPress's
+ // shortcode attribute pattern would otherwise cut the value at the rules array.
+ $attribute = str_replace(
+ array( '[', ']' ),
+ array( '[', ']' ),
+ esc_attr( (string) wp_json_encode( $logic, JSON_UNESCAPED_SLASHES | JSON_HEX_AMP | JSON_HEX_TAG ) )
+ );
+
+ $shortcode = '[contact-form id="cl-required" submit_button_text="Submit"]'
+ . '[contact-field id="trigger" type="text" label="Trigger"/]'
+ . '[contact-field id="dependent" type="text" label="Dependent" required="1"'
+ . ' conditionallogic="' . $attribute . '"/]'
+ . '[/contact-form]';
+
+ do_shortcode( $shortcode );
+
+ return Contact_Form::$last;
+ }
+
+ /**
+ * The parse pass must leave the conditional field alone.
+ *
+ * If it records an error here, no later pass can clear it and the visitor is stuck on a
+ * field they cannot see.
+ */
+ public function test_the_parse_pass_defers_a_conditional_field() {
+ $form = $this->parse_form( 'Something else', '' );
+
+ $this->assertFalse(
+ $form->has_errors(),
+ 'Parsing must not validate a conditional field, whose subject may not exist yet.'
+ );
+ }
+
+ /**
+ * The bad state: the visitor cannot see the field, cannot fill it, and submitting does
+ * nothing because the form reports an error against it.
+ */
+ public function test_hidden_required_field_does_not_block_submission() {
+ $form = $this->parse_form( 'Something else', '' );
+ $form->validate();
+
+ $this->assertFalse(
+ $form->has_errors(),
+ 'A required field hidden by conditional logic must not put the form in an error state.'
+ );
+ }
+
+ public function test_visible_required_field_still_blocks_submission() {
+ $form = $this->parse_form( 'Other', '' );
+ $form->validate();
+
+ $this->assertTrue(
+ $form->has_errors(),
+ 'Once the condition is met the field is shown, so its required rule applies again.'
+ );
+ }
+
+ public function test_visible_required_field_with_an_answer_passes() {
+ $form = $this->parse_form( 'Other', 'an answer' );
+ $form->validate();
+
+ $this->assertFalse( $form->has_errors() );
+ }
+
+ /**
+ * Parsing alone is not enough to catch an invalid conditional field.
+ *
+ * This is the shape of the legacy (non-JWT) submission path, which used to go straight
+ * from parsing to storing. Deferring the field's validation is only safe because
+ * something runs validate() afterwards; if a submission path ever skips it, an empty
+ * required field is accepted and stored.
+ */
+ public function test_a_visible_required_field_is_only_caught_once_validate_runs() {
+ $form = $this->parse_form( 'Other', '' );
+
+ $this->assertFalse(
+ $form->has_errors(),
+ 'Deferred at parse time, so nothing is recorded yet.'
+ );
+
+ $form->validate();
+
+ $this->assertTrue(
+ $form->has_errors(),
+ 'Every submission path must call validate(), or this field is stored unchecked.'
+ );
+ }
+}
diff --git a/projects/packages/forms/tests/php/contact-form/Conditional_Logic_Test.php b/projects/packages/forms/tests/php/contact-form/Conditional_Logic_Test.php
new file mode 100644
index 000000000000..e3223a1a4fd0
--- /dev/null
+++ b/projects/packages/forms/tests/php/contact-form/Conditional_Logic_Test.php
@@ -0,0 +1,875 @@
+ true,
+ 'action' => 'show',
+ 'logicalOperator' => 'any',
+ 'groups' => array(
+ array(
+ 'logicalOperator' => $group_operator,
+ 'rules' => $rules,
+ ),
+ ),
+ ),
+ $overrides
+ );
+ }
+
+ /**
+ * Build a single-rule logic config.
+ *
+ * @param string $operator Operator wire string.
+ * @param mixed $value Expected value.
+ * @param string $field Subject field id.
+ *
+ * @return array
+ */
+ private function one( $operator, $value = '', $field = 'a' ): array {
+ return $this->logic(
+ array(
+ array(
+ 'field' => $field,
+ 'operator' => $operator,
+ 'value' => $value,
+ ),
+ )
+ );
+ }
+
+ /**
+ * Every shortcode field type and its comparison behavior.
+ *
+ * @return array
+ */
+ public static function provide_field_types(): array {
+ return array(
+ array( 'text', 'string' ),
+ array( 'name', 'string' ),
+ array( 'email', 'string' ),
+ array( 'url', 'string' ),
+ array( 'textarea', 'string' ),
+ array( 'telephone', 'string' ),
+ array( 'phone', 'string' ),
+ array( 'select', 'choice' ),
+ array( 'radio', 'choice' ),
+ array( 'image-select', 'choice' ),
+ array( 'checkbox-multiple', 'multichoice' ),
+ array( 'number', 'number' ),
+ array( 'slider', 'number' ),
+ array( 'rating', 'rating' ),
+ array( 'date', 'date' ),
+ array( 'time', 'time' ),
+ array( 'checkbox', 'boolean' ),
+ array( 'consent', 'boolean' ),
+ array( 'hidden', 'hidden' ),
+ array( 'file', 'file' ),
+ );
+ }
+
+ /**
+ * @param string $field_type Shortcode type.
+ * @param string $expected Expected type key.
+ * @dataProvider provide_field_types
+ */
+ #[DataProvider( 'provide_field_types' )]
+ public function test_type_key_for_field_type( $field_type, $expected ) {
+ $this->assertSame( $expected, Conditional_Logic::type_key_for_field_type( $field_type ) );
+ }
+
+ public function test_unknown_field_type_compares_textually() {
+ $this->assertSame( 'string', Conditional_Logic::type_key_for_field_type( 'not-a-type' ) );
+ $this->assertSame( 'string', Conditional_Logic::type_key_for_field_type( '' ) );
+ }
+
+ /**
+ * String operator cases.
+ *
+ * @return array
+ */
+ public static function provide_string_operators(): array {
+ return array(
+ array( 'is', 'yes', 'yes', true ),
+ array( 'is', 'yes', 'no', false ),
+ array( 'is_not', 'yes', 'no', true ),
+ array( 'is_not', 'yes', 'yes', false ),
+ array( 'contains', 'blueberry', 'blue', true ),
+ array( 'contains', 'blueberry', 'red', false ),
+ array( 'does_not_contain', 'blueberry', 'red', true ),
+ array( 'does_not_contain', 'blueberry', 'blue', false ),
+ );
+ }
+
+ /**
+ * @param string $operator Operator.
+ * @param string $actual Submitted value.
+ * @param string $expected Rule value.
+ * @param bool $want Expected visibility.
+ * @dataProvider provide_string_operators
+ */
+ #[DataProvider( 'provide_string_operators' )]
+ public function test_string_operators( $operator, $actual, $expected, $want ) {
+ $this->assertSame(
+ $want,
+ Conditional_Logic::evaluate(
+ $this->one( $operator, $expected ),
+ array( 'a' => 'text' ),
+ array( 'a' => $actual )
+ )
+ );
+ }
+
+ public function test_empty_operators() {
+ $types = array( 'a' => 'text' );
+ $this->assertTrue( Conditional_Logic::evaluate( $this->one( 'is_empty' ), $types, array( 'a' => '' ) ) );
+ $this->assertTrue( Conditional_Logic::evaluate( $this->one( 'is_empty' ), $types, array( 'a' => ' ' ) ) );
+ $this->assertFalse( Conditional_Logic::evaluate( $this->one( 'is_empty' ), $types, array( 'a' => 'x' ) ) );
+ $this->assertTrue( Conditional_Logic::evaluate( $this->one( 'is_not_empty' ), $types, array( 'a' => 'x' ) ) );
+ $this->assertFalse( Conditional_Logic::evaluate( $this->one( 'is_not_empty' ), $types, array( 'a' => '' ) ) );
+ }
+
+ public function test_multichoice_uses_membership_not_substring() {
+ $types = array( 'a' => 'checkbox-multiple' );
+
+ // "Blue" must not match an option named "Blueberry".
+ $this->assertFalse(
+ Conditional_Logic::evaluate( $this->one( 'contains', 'Blue' ), $types, array( 'a' => array( 'Blueberry' ) ) )
+ );
+ $this->assertTrue(
+ Conditional_Logic::evaluate( $this->one( 'contains', 'Blue' ), $types, array( 'a' => array( 'Blue', 'Red' ) ) )
+ );
+ }
+
+ public function test_multichoice_does_not_contain() {
+ $types = array( 'a' => 'checkbox-multiple' );
+ $this->assertTrue(
+ Conditional_Logic::evaluate( $this->one( 'does_not_contain', 'Blue' ), $types, array( 'a' => array( 'Red' ) ) )
+ );
+ $this->assertFalse(
+ Conditional_Logic::evaluate( $this->one( 'does_not_contain', 'Blue' ), $types, array( 'a' => array( 'Blue' ) ) )
+ );
+ }
+
+ public function test_multichoice_is_not_confused_by_commas_in_labels() {
+ $types = array( 'a' => 'checkbox-multiple' );
+ $this->assertFalse(
+ Conditional_Logic::evaluate( $this->one( 'contains', 'Yes' ), $types, array( 'a' => array( 'Yes, please' ) ) )
+ );
+ $this->assertTrue(
+ Conditional_Logic::evaluate(
+ $this->one( 'contains', 'Yes, please' ),
+ $types,
+ array( 'a' => array( 'Yes, please' ) )
+ )
+ );
+ }
+
+ public function test_multichoice_accepts_a_single_string_selection() {
+ $this->assertTrue(
+ Conditional_Logic::evaluate(
+ $this->one( 'contains', 'Blue' ),
+ array( 'a' => 'checkbox-multiple' ),
+ array( 'a' => 'Blue' )
+ )
+ );
+ }
+
+ public function test_choice_compares_the_whole_option() {
+ $types = array( 'a' => 'select' );
+ $this->assertTrue( Conditional_Logic::evaluate( $this->one( 'is', 'Blue' ), $types, array( 'a' => 'Blue' ) ) );
+ $this->assertFalse( Conditional_Logic::evaluate( $this->one( 'is', 'Blue' ), $types, array( 'a' => 'Blueberry' ) ) );
+ $this->assertTrue( Conditional_Logic::evaluate( $this->one( 'is_not', 'Blue' ), $types, array( 'a' => 'Red' ) ) );
+ }
+
+ /**
+ * Numeric operator cases.
+ *
+ * @return array
+ */
+ public static function provide_numeric_operators(): array {
+ return array(
+ array( 'equals', '10.0', '10', true ),
+ array( 'equals', '11', '10', false ),
+ array( 'not_equals', '11', '10', true ),
+ array( 'greater_than', '20', '10', true ),
+ array( 'greater_than', '10', '10', false ),
+ array( 'less_than', '5', '10', true ),
+ array( 'less_than', '10', '10', false ),
+ array( 'gte', '10', '10', true ),
+ array( 'gte', '9', '10', false ),
+ array( 'lte', '10', '10', true ),
+ array( 'lte', '11', '10', false ),
+ );
+ }
+
+ /**
+ * @param string $operator Operator.
+ * @param string $actual Submitted value.
+ * @param string $expected Rule value.
+ * @param bool $want Expected visibility.
+ * @dataProvider provide_numeric_operators
+ */
+ #[DataProvider( 'provide_numeric_operators' )]
+ public function test_numeric_operators( $operator, $actual, $expected, $want ) {
+ $this->assertSame(
+ $want,
+ Conditional_Logic::evaluate(
+ $this->one( $operator, $expected ),
+ array( 'a' => 'number' ),
+ array( 'a' => $actual )
+ )
+ );
+ }
+
+ public function test_numeric_rule_fails_when_either_side_is_not_numeric() {
+ $types = array( 'a' => 'number' );
+ $this->assertFalse( Conditional_Logic::evaluate( $this->one( 'greater_than', '10' ), $types, array( 'a' => 'abc' ) ) );
+ $this->assertFalse( Conditional_Logic::evaluate( $this->one( 'greater_than', 'abc' ), $types, array( 'a' => '20' ) ) );
+ $this->assertFalse( Conditional_Logic::evaluate( $this->one( 'greater_than', '10' ), $types, array( 'a' => '' ) ) );
+ }
+
+ public function test_numbers_compare_numerically_not_lexically() {
+ // '9' > '10' as strings, but 9 < 10 as numbers.
+ $this->assertFalse(
+ Conditional_Logic::evaluate( $this->one( 'greater_than', '10' ), array( 'a' => 'number' ), array( 'a' => '9' ) )
+ );
+ }
+
+ /**
+ * Date and time operator cases.
+ *
+ * @return array
+ */
+ public static function provide_temporal_operators(): array {
+ return array(
+ array( 'date', 'before', '2026-01-01', '2026-06-01', true ),
+ array( 'date', 'before', '2026-12-01', '2026-06-01', false ),
+ array( 'date', 'after', '2026-12-01', '2026-06-01', true ),
+ array( 'date', 'after', '2026-01-01', '2026-06-01', false ),
+ array( 'date', 'is', '2026-06-01', '2026-06-01', true ),
+ array( 'date', 'is_not', '2026-06-02', '2026-06-01', true ),
+ array( 'time', 'before', '09:00', '17:00', true ),
+ array( 'time', 'after', '18:00', '17:00', true ),
+ array( 'time', 'is', '17:00', '17:00', true ),
+ );
+ }
+
+ /**
+ * @param string $field_type Shortcode type (date or time).
+ * @param string $operator Operator.
+ * @param string $actual Submitted value.
+ * @param string $expected Rule value.
+ * @param bool $want Expected visibility.
+ * @dataProvider provide_temporal_operators
+ */
+ #[DataProvider( 'provide_temporal_operators' )]
+ public function test_temporal_operators( $field_type, $operator, $actual, $expected, $want ) {
+ $this->assertSame(
+ $want,
+ Conditional_Logic::evaluate(
+ $this->one( $operator, $expected ),
+ array( 'a' => $field_type ),
+ array( 'a' => $actual )
+ )
+ );
+ }
+
+ public function test_temporal_rule_fails_when_unparseable() {
+ $this->assertFalse(
+ Conditional_Logic::evaluate( $this->one( 'before', '2026-06-01' ), array( 'a' => 'date' ), array( 'a' => 'nonsense' ) )
+ );
+ $this->assertFalse(
+ Conditional_Logic::evaluate( $this->one( 'before', 'nonsense' ), array( 'a' => 'date' ), array( 'a' => '2026-06-01' ) )
+ );
+ }
+
+ public function test_boolean_operators() {
+ $types = array( 'a' => 'checkbox' );
+ $this->assertTrue( Conditional_Logic::evaluate( $this->one( 'is_checked' ), $types, array( 'a' => true ) ) );
+ $this->assertFalse( Conditional_Logic::evaluate( $this->one( 'is_checked' ), $types, array( 'a' => false ) ) );
+ $this->assertTrue( Conditional_Logic::evaluate( $this->one( 'is_checked' ), $types, array( 'a' => '1' ) ) );
+ $this->assertFalse( Conditional_Logic::evaluate( $this->one( 'is_checked' ), $types, array( 'a' => '' ) ) );
+ $this->assertTrue( Conditional_Logic::evaluate( $this->one( 'is_not_checked' ), $types, array( 'a' => false ) ) );
+ $this->assertFalse( Conditional_Logic::evaluate( $this->one( 'is_not_checked' ), $types, array( 'a' => true ) ) );
+ }
+
+ public function test_any_versus_all() {
+ $types = array(
+ 'a' => 'text',
+ 'b' => 'text',
+ );
+ $rules = array(
+ array(
+ 'field' => 'a',
+ 'operator' => 'is',
+ 'value' => 'x',
+ ),
+ array(
+ 'field' => 'b',
+ 'operator' => 'is',
+ 'value' => 'y',
+ ),
+ );
+ $values = array(
+ 'a' => 'x',
+ 'b' => 'nope',
+ );
+
+ $this->assertTrue(
+ Conditional_Logic::evaluate( $this->logic( $rules, array( 'logicalOperator' => 'any' ) ), $types, $values )
+ );
+ $this->assertFalse(
+ Conditional_Logic::evaluate( $this->logic( $rules, array( 'logicalOperator' => 'all' ) ), $types, $values )
+ );
+ }
+
+ public function test_hide_action_inverts_the_outcome() {
+ $types = array( 'a' => 'text' );
+ $rules = array(
+ array(
+ 'field' => 'a',
+ 'operator' => 'is',
+ 'value' => 'x',
+ ),
+ );
+ $this->assertTrue( Conditional_Logic::evaluate( $this->logic( $rules ), $types, array( 'a' => 'x' ) ) );
+ $this->assertFalse(
+ Conditional_Logic::evaluate( $this->logic( $rules, array( 'action' => 'hide' ) ), $types, array( 'a' => 'x' ) )
+ );
+ }
+
+ public function test_visible_when_disabled_ruleless_or_missing_control() {
+ $types = array( 'a' => 'text' );
+ $rules = array(
+ array(
+ 'field' => 'a',
+ 'operator' => 'is',
+ 'value' => 'x',
+ ),
+ );
+
+ $this->assertTrue( Conditional_Logic::evaluate( $this->logic( array() ), $types, array() ) );
+ $this->assertTrue(
+ Conditional_Logic::evaluate( $this->logic( $rules, array( 'enabled' => false ) ), $types, array() )
+ );
+ $this->assertTrue(
+ Conditional_Logic::evaluate(
+ array(
+ 'enabled' => true,
+ 'action' => 'show',
+ 'logicalOperator' => 'all',
+ 'controls' => array(),
+ ),
+ $types,
+ array()
+ )
+ );
+ $this->assertTrue( Conditional_Logic::evaluate( null, $types, array() ) );
+ }
+
+ public function test_rule_with_missing_subject_field_is_ignored() {
+ // A deleted subject must not be compared against empty: that would make is_empty
+ // spuriously true and hide the field because an unrelated block was removed.
+ $types = array( 'a' => 'text' );
+ $this->assertTrue( Conditional_Logic::evaluate( $this->one( 'is_empty', '', 'gone' ), $types, array() ) );
+ $this->assertTrue( Conditional_Logic::evaluate( $this->one( 'is', 'x', 'gone' ), $types, array() ) );
+ }
+
+ public function test_only_the_missing_rule_is_ignored() {
+ $types = array( 'a' => 'text' );
+ $rules = array(
+ array(
+ 'field' => 'gone',
+ 'operator' => 'is',
+ 'value' => 'x',
+ ),
+ array(
+ 'field' => 'a',
+ 'operator' => 'is',
+ 'value' => 'x',
+ ),
+ );
+ $this->assertTrue( Conditional_Logic::evaluate( $this->logic( $rules ), $types, array( 'a' => 'x' ) ) );
+ $this->assertFalse( Conditional_Logic::evaluate( $this->logic( $rules ), $types, array( 'a' => 'nope' ) ) );
+ }
+
+ public function test_unknown_operator_is_ignored() {
+ $this->assertTrue(
+ Conditional_Logic::evaluate(
+ $this->one( 'not_a_real_operator', 'x' ),
+ array( 'a' => 'text' ),
+ array( 'a' => 'x' )
+ )
+ );
+ }
+
+ /**
+ * Build the A -> B -> C chain used by the cascade tests.
+ *
+ * @return array
+ */
+ private function chain(): array {
+ return array(
+ 'a' => array(
+ 'logic' => null,
+ 'type' => 'text',
+ ),
+ 'b' => array(
+ 'logic' => $this->one( 'is', 'Other' ),
+ 'type' => 'text',
+ ),
+ 'c' => array(
+ 'logic' => $this->logic(
+ array(
+ array(
+ 'field' => 'b',
+ 'operator' => 'is_not_empty',
+ ),
+ )
+ ),
+ 'type' => 'text',
+ ),
+ );
+ }
+
+ public function test_cascade_treats_hidden_field_as_empty() {
+ // A switched away from Other so B hides. B still holds a stale value, but C must not
+ // stay visible on the strength of an answer the visitor can no longer see.
+ $visible = Conditional_Logic::resolve_visibility(
+ $this->chain(),
+ array(
+ 'a' => 'Something else',
+ 'b' => 'xyz',
+ 'c' => '',
+ )
+ );
+
+ $this->assertTrue( $visible['a'] );
+ $this->assertFalse( $visible['b'] );
+ $this->assertFalse( $visible['c'] );
+ }
+
+ public function test_cascade_keeps_the_chain_visible_when_the_trigger_matches() {
+ $visible = Conditional_Logic::resolve_visibility(
+ $this->chain(),
+ array(
+ 'a' => 'Other',
+ 'b' => 'xyz',
+ 'c' => '',
+ )
+ );
+
+ $this->assertTrue( $visible['b'] );
+ $this->assertTrue( $visible['c'] );
+ }
+
+ public function test_cascade_resolves_a_three_deep_chain() {
+ $fields = $this->chain();
+ $fields['d'] = array(
+ 'logic' => $this->logic(
+ array(
+ array(
+ 'field' => 'c',
+ 'operator' => 'is_not_empty',
+ ),
+ )
+ ),
+ 'type' => 'text',
+ );
+
+ $visible = Conditional_Logic::resolve_visibility(
+ $fields,
+ array(
+ 'a' => 'Other',
+ 'b' => 'x',
+ 'c' => 'y',
+ 'd' => '',
+ )
+ );
+ $this->assertTrue( $visible['d'] );
+
+ $hidden = Conditional_Logic::resolve_visibility(
+ $fields,
+ array(
+ 'a' => 'stop',
+ 'b' => 'x',
+ 'c' => 'y',
+ 'd' => '',
+ )
+ );
+ $this->assertFalse( $hidden['b'] );
+ $this->assertFalse( $hidden['c'] );
+ $this->assertFalse( $hidden['d'] );
+ }
+
+ public function test_two_field_cycle_fails_open() {
+ $fields = array(
+ 'a' => array(
+ 'logic' => $this->logic(
+ array(
+ array(
+ 'field' => 'b',
+ 'operator' => 'is_empty',
+ ),
+ )
+ ),
+ 'type' => 'text',
+ ),
+ 'b' => array(
+ 'logic' => $this->logic(
+ array(
+ array(
+ 'field' => 'a',
+ 'operator' => 'is_not_empty',
+ ),
+ )
+ ),
+ 'type' => 'text',
+ ),
+ );
+
+ $visible = Conditional_Logic::resolve_visibility(
+ $fields,
+ array(
+ 'a' => 'x',
+ 'b' => 'y',
+ )
+ );
+
+ $this->assertTrue( $visible['a'] );
+ $this->assertTrue( $visible['b'] );
+ }
+
+ public function test_self_reference_fails_open() {
+ $fields = array(
+ 'a' => array(
+ 'logic' => $this->logic(
+ array(
+ array(
+ 'field' => 'a',
+ 'operator' => 'is_empty',
+ ),
+ )
+ ),
+ 'type' => 'text',
+ ),
+ );
+
+ $visible = Conditional_Logic::resolve_visibility( $fields, array( 'a' => 'x' ) );
+
+ $this->assertTrue( $visible['a'] );
+ }
+
+ public function test_every_field_visible_when_none_has_logic() {
+ $fields = array(
+ 'a' => array(
+ 'logic' => null,
+ 'type' => 'text',
+ ),
+ 'b' => array(
+ 'logic' => null,
+ 'type' => 'text',
+ ),
+ );
+
+ $this->assertSame(
+ array(
+ 'a' => true,
+ 'b' => true,
+ ),
+ Conditional_Logic::resolve_visibility( $fields, array() )
+ );
+ }
+
+ public function test_resolve_visibility_returns_an_entry_for_every_field() {
+ $visible = Conditional_Logic::resolve_visibility(
+ $this->chain(),
+ array(
+ 'a' => 'Other',
+ 'b' => 'x',
+ 'c' => '',
+ )
+ );
+ $keys = array_keys( $visible );
+ sort( $keys );
+ $this->assertSame( array( 'a', 'b', 'c' ), $keys );
+ }
+
+ public function test_resolve_visibility_tolerates_an_empty_field_map() {
+ $this->assertSame( array(), Conditional_Logic::resolve_visibility( array(), array() ) );
+ }
+
+ /**
+ * A deep chain must still reach its fixed point.
+ *
+ * The pass budget used to be clamped to a constant, so an acyclic chain deeper than the
+ * clamp ran out of passes, was read as circular, and failed open -- leaving fields
+ * visible that every rule said to hide.
+ */
+ public function test_a_deep_acyclic_chain_resolves_completely() {
+ $depth = 30;
+ $descriptors = array( 'f0' => array( 'type' => 'text' ) );
+ $values = array( 'f0' => 'no' );
+
+ // Each field is shown only when the one before it says 'yes'. f0 says 'no', so every
+ // field downstream of it must resolve hidden.
+ for ( $i = 1; $i <= $depth; $i++ ) {
+ $descriptors[ "f$i" ] = array(
+ 'type' => 'text',
+ 'logic' => array(
+ 'enabled' => true,
+ 'action' => 'show',
+ 'logicalOperator' => 'all',
+ 'groups' => array(
+ array(
+ 'logicalOperator' => 'all',
+ 'rules' => array(
+ array(
+ 'field' => 'f' . ( $i - 1 ),
+ 'operator' => 'is',
+ 'value' => 'yes',
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ $values[ "f$i" ] = 'yes';
+ }
+
+ $visible = Conditional_Logic::resolve_visibility( $descriptors, $values );
+
+ $this->assertTrue( $visible['f0'], 'The unconditional field is always visible.' );
+ for ( $i = 1; $i <= $depth; $i++ ) {
+ $this->assertFalse(
+ $visible[ "f$i" ],
+ "Field f$i is downstream of a false condition and must be hidden."
+ );
+ }
+ }
+
+ /**
+ * Build two groups over three fields.
+ *
+ * @param string $outer How the groups combine.
+ * @param string $first How the first group's own rules combine.
+ * @param string $second How the second group's own rules combine.
+ *
+ * @return array
+ */
+ private function two_groups( string $outer, string $first, string $second ): array {
+ return array(
+ 'enabled' => true,
+ 'action' => 'show',
+ 'logicalOperator' => $outer,
+ 'groups' => array(
+ array(
+ 'logicalOperator' => $first,
+ 'rules' => array(
+ array(
+ 'field' => 'a',
+ 'operator' => 'is',
+ 'value' => 'yes',
+ ),
+ array(
+ 'field' => 'b',
+ 'operator' => 'is',
+ 'value' => 'yes',
+ ),
+ ),
+ ),
+ array(
+ 'logicalOperator' => $second,
+ 'rules' => array(
+ array(
+ 'field' => 'c',
+ 'operator' => 'is',
+ 'value' => 'yes',
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+
+ /**
+ * The shape stores groups so that "any of these AND all of those" becomes possible without
+ * another migration. The V1 panel writes one group, but the evaluator has to handle
+ * several already. Mirrored in evaluate.test.js.
+ */
+ public function test_groups_combine_with_all() {
+ $types = array(
+ 'a' => 'text',
+ 'b' => 'text',
+ 'c' => 'text',
+ );
+
+ $this->assertTrue(
+ Conditional_Logic::evaluate(
+ $this->two_groups( 'all', 'any', 'all' ),
+ $types,
+ array(
+ 'a' => 'no',
+ 'b' => 'yes',
+ 'c' => 'yes',
+ )
+ ),
+ 'First group satisfied by b alone, second by c.'
+ );
+
+ $this->assertFalse(
+ Conditional_Logic::evaluate(
+ $this->two_groups( 'all', 'any', 'all' ),
+ $types,
+ array(
+ 'a' => 'no',
+ 'b' => 'yes',
+ 'c' => 'no',
+ )
+ ),
+ 'The second group fails, so the whole condition fails.'
+ );
+ }
+
+ public function test_groups_combine_with_any() {
+ $types = array(
+ 'a' => 'text',
+ 'b' => 'text',
+ 'c' => 'text',
+ );
+
+ $this->assertTrue(
+ Conditional_Logic::evaluate(
+ $this->two_groups( 'any', 'all', 'all' ),
+ $types,
+ array(
+ 'a' => 'no',
+ 'b' => 'yes',
+ 'c' => 'yes',
+ )
+ ),
+ 'The first group needs both and gets one; the second carries it.'
+ );
+
+ $this->assertFalse(
+ Conditional_Logic::evaluate(
+ $this->two_groups( 'any', 'all', 'all' ),
+ $types,
+ array(
+ 'a' => 'no',
+ 'b' => 'yes',
+ 'c' => 'no',
+ )
+ )
+ );
+ }
+
+ /**
+ * A group naming a field that no longer exists drops out rather than dragging the field
+ * into hiding under an `all`.
+ */
+ public function test_a_group_with_nothing_evaluable_is_ignored() {
+ $logic = array(
+ 'enabled' => true,
+ 'action' => 'show',
+ 'logicalOperator' => 'all',
+ 'groups' => array(
+ array(
+ 'logicalOperator' => 'all',
+ 'rules' => array(
+ array(
+ 'field' => 'a',
+ 'operator' => 'is',
+ 'value' => 'yes',
+ ),
+ ),
+ ),
+ array(
+ 'logicalOperator' => 'all',
+ 'rules' => array(
+ array(
+ 'field' => 'gone',
+ 'operator' => 'is',
+ 'value' => 'yes',
+ ),
+ ),
+ ),
+ ),
+ );
+
+ $this->assertTrue(
+ Conditional_Logic::evaluate( $logic, array( 'a' => 'text' ), array( 'a' => 'yes' ) )
+ );
+ }
+
+ /**
+ * A form saved by a newer editor degrades to the conditions this release understands
+ * rather than breaking on the ones it does not.
+ */
+ public function test_a_rule_of_an_unknown_kind_is_ignored() {
+ $logic = array(
+ 'enabled' => true,
+ 'action' => 'show',
+ 'logicalOperator' => 'all',
+ 'groups' => array(
+ array(
+ 'logicalOperator' => 'all',
+ 'rules' => array(
+ array(
+ 'type' => 'fieldValue',
+ 'field' => 'a',
+ 'operator' => 'is',
+ 'value' => 'yes',
+ ),
+ array(
+ 'type' => 'queryString',
+ 'field' => 'utm_source',
+ 'operator' => 'is',
+ 'value' => 'ads',
+ ),
+ ),
+ ),
+ ),
+ );
+
+ $this->assertTrue(
+ Conditional_Logic::evaluate( $logic, array( 'a' => 'text' ), array( 'a' => 'yes' ) )
+ );
+ }
+}
diff --git a/projects/packages/forms/tests/php/contact-form/Conditional_Logic_Validation_Test.php b/projects/packages/forms/tests/php/contact-form/Conditional_Logic_Validation_Test.php
new file mode 100644
index 000000000000..ff8c67d6f9a9
--- /dev/null
+++ b/projects/packages/forms/tests/php/contact-form/Conditional_Logic_Validation_Test.php
@@ -0,0 +1,194 @@
+ 'cf-validation-test' ) );
+
+ $trigger = new Contact_Form_Field(
+ array(
+ 'id' => 'trigger',
+ 'type' => 'text',
+ 'label' => 'Trigger',
+ ),
+ '',
+ $form
+ );
+
+ $dependent = new Contact_Form_Field(
+ array(
+ 'id' => 'dependent',
+ 'type' => 'text',
+ 'label' => 'Dependent',
+ // Contact_Form_Field normalizes this attribute: only the strings '1' and 'true'
+ // count as required, matching what the shortcode emits.
+ 'required' => '1',
+ 'conditionallogic' => array(
+ 'enabled' => true,
+ 'action' => 'show',
+ 'logicalOperator' => 'all',
+ 'groups' => array(
+ array(
+ 'logicalOperator' => 'all',
+ 'rules' => array(
+ array(
+ 'field' => 'trigger',
+ 'operator' => 'is',
+ 'value' => 'Other',
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ '',
+ $form
+ );
+
+ // Contact_Form_Field::validate() reads $_POST, so the submitted values have to live
+ // there for this to exercise the real validation path.
+ $_POST['trigger'] = $trigger_value;
+ $_POST['dependent'] = $dependent_value;
+
+ $trigger->value = $trigger_value;
+ $dependent->value = $dependent_value;
+
+ $form->fields = array(
+ 'trigger' => $trigger,
+ 'dependent' => $dependent,
+ );
+
+ return $form;
+ }
+
+ /**
+ * Regression: before conditional logic reached the validation loop, a required field
+ * hidden by its own rule was still validated, so submitting blocked on an error about a
+ * field the visitor could neither see nor fill.
+ */
+ public function test_hidden_required_field_does_not_block_submission() {
+ // Trigger is not "Other", so the required dependent field is hidden.
+ $form = $this->build_form( 'Something else', '' );
+
+ $form->validate();
+
+ $this->assertFalse(
+ $form->has_errors(),
+ 'A required field hidden by conditional logic must not produce a validation error.'
+ );
+ }
+
+ public function test_visible_required_field_still_blocks_submission() {
+ // Trigger matches, so the dependent field is shown and its requirement stands.
+ $form = $this->build_form( 'Other', '' );
+
+ $form->validate();
+
+ $this->assertTrue(
+ $form->has_errors(),
+ 'A visible required field must still be enforced.'
+ );
+ }
+
+ public function test_visible_required_field_with_a_value_passes() {
+ $form = $this->build_form( 'Other', 'an answer' );
+
+ $form->validate();
+
+ $this->assertFalse( $form->has_errors() );
+ }
+
+ /**
+ * The resolver is shared between validation and storage, so it must report on every field.
+ */
+ public function test_resolved_visibility_covers_every_field() {
+ $form = $this->build_form( 'Something else', '' );
+
+ $visibility = $form->get_resolved_field_visibility();
+
+ $this->assertSame(
+ array( 'trigger', 'dependent' ),
+ array_keys( $visibility )
+ );
+ $this->assertTrue( $visibility['trigger'] );
+ $this->assertFalse( $visibility['dependent'] );
+ }
+
+ public function test_conditional_logic_context_is_empty_without_conditions() {
+ $form = new Contact_Form( array( 'id' => 'cf-plain' ) );
+ $field = new Contact_Form_Field(
+ array(
+ 'id' => 'plain',
+ 'type' => 'text',
+ ),
+ '',
+ $form
+ );
+
+ $form->fields = array( 'plain' => $field );
+
+ $this->assertSame(
+ array(),
+ $form->get_conditional_logic_context(),
+ 'A form with no conditions must not add anything to the page.'
+ );
+ }
+
+ /**
+ * Types are emitted for every field, not only the ones carrying logic: any field can be
+ * the subject of a rule, and the evaluator ignores rules whose subject it cannot type.
+ */
+ public function test_conditional_logic_context_types_cover_every_field() {
+ $form = $this->build_form( 'Other', '' );
+
+ $context = $form->get_conditional_logic_context();
+
+ $this->assertArrayHasKey( 'types', $context );
+ $this->assertArrayHasKey( 'logic', $context );
+ $this->assertSame(
+ array( 'trigger', 'dependent' ),
+ array_keys( $context['types'] ),
+ 'Every field needs a type so it can be referenced by a rule.'
+ );
+ $this->assertSame(
+ array( 'dependent' ),
+ array_keys( $context['logic'] ),
+ 'Only fields that carry conditions need their logic emitted.'
+ );
+ }
+}
diff --git a/projects/packages/forms/tests/php/contact-form/Contact_Form_Field_Test.php b/projects/packages/forms/tests/php/contact-form/Contact_Form_Field_Test.php
index 52592a073744..38cb956c3954 100644
--- a/projects/packages/forms/tests/php/contact-form/Contact_Form_Field_Test.php
+++ b/projects/packages/forms/tests/php/contact-form/Contact_Form_Field_Test.php
@@ -116,6 +116,127 @@ public function test_handles_get_array_value() {
$this->assertEquals( array( 'value1', 'value2' ), $result );
}
+ /**
+ * A missing checkbox value on a real submission means the visitor unchecked it.
+ * It must not be repopulated from the query string or configured default.
+ *
+ * @dataProvider submitted_unchecked_field_provider
+ *
+ * @param string $field_type Field type.
+ * @param array $attributes Additional field attributes.
+ */
+ #[DataProvider( 'submitted_unchecked_field_provider' )]
+ public function test_submitted_unchecked_fields_do_not_fall_back_to_prefills( $field_type, $attributes ) {
+ $form = new Contact_Form( array( 'id' => 'submitted-form' ) );
+ $field = new Contact_Form_Field(
+ array_merge(
+ array(
+ 'id' => 'choice',
+ 'type' => $field_type,
+ 'default' => 'Yes',
+ ),
+ $attributes
+ ),
+ '',
+ $form
+ );
+
+ $_GET['choice'] = 'Yes';
+ $_POST['action'] = 'grunion-contact-form';
+ $_POST['contact-form-id'] = $form->get_attribute( 'id' );
+ $_POST['contact-form-hash'] = $form->hash;
+
+ $this->assertSame( '', $field->get_computed_field_value( $field_type, 'choice' ) );
+ }
+
+ /**
+ * Field types represented by checkbox controls.
+ *
+ * @return array
+ */
+ public static function submitted_unchecked_field_provider() {
+ return array(
+ 'checkbox' => array( 'checkbox', array() ),
+ 'checkbox-multiple' => array( 'checkbox-multiple', array() ),
+ 'explicit consent' => array( 'consent', array( 'consenttype' => 'explicit' ) ),
+ );
+ }
+
+ /**
+ * Explicit consent never renders checked, so prefills must not satisfy its conditions.
+ *
+ * @dataProvider explicit_consent_prefill_provider
+ *
+ * @param string $default Configured default value.
+ * @param string $get_value Query-string value.
+ */
+ #[DataProvider( 'explicit_consent_prefill_provider' )]
+ public function test_explicit_consent_conditional_value_ignores_prefills( $default, $get_value ) {
+ $field = $this->get_new_field_instance(
+ array(
+ 'type' => 'consent',
+ 'id' => 'choice',
+ 'default' => $default,
+ 'consenttype' => 'explicit',
+ )
+ );
+
+ if ( '' !== $get_value ) {
+ $_GET['choice'] = $get_value;
+ }
+
+ $this->assertSame( '', $field->get_conditional_logic_value() );
+ }
+
+ /**
+ * Prefills that cannot check an explicit-consent control.
+ *
+ * @return array
+ */
+ public static function explicit_consent_prefill_provider() {
+ return array(
+ 'configured default' => array( 'Yes', '' ),
+ 'query string' => array( '', 'Yes' ),
+ );
+ }
+
+ /**
+ * Implicit consent is represented by a hidden input whose submitted value is always Yes.
+ */
+ public function test_implicit_consent_computed_value_matches_its_hidden_input() {
+ $field = $this->get_new_field_instance(
+ array(
+ 'type' => 'consent',
+ 'id' => 'test_consent',
+ 'consenttype' => 'implicit',
+ )
+ );
+
+ $this->assertSame( 'Yes', $field->get_computed_field_value( 'consent', 'test_consent' ) );
+ }
+
+ /**
+ * JWT submissions omit the normal form action but still represent a submitted checkbox.
+ */
+ public function test_submitted_unchecked_field_is_empty_for_jwt_submission() {
+ $form = new Contact_Form( array( 'id' => 'submitted-form' ) );
+ $field = new Contact_Form_Field(
+ array(
+ 'id' => 'choice',
+ 'type' => 'checkbox',
+ 'default' => 'Yes',
+ ),
+ '',
+ $form
+ );
+
+ $_POST['contact-form-id'] = $form->get_attribute( 'id' );
+ $_POST['contact-form-hash'] = $form->hash;
+ $_POST['jetpack_contact_form_jwt'] = 'validated-by-submission-handler';
+
+ $this->assertSame( '', $field->get_computed_field_value( 'checkbox', 'choice' ) );
+ }
+
/**
* Test logged-in user email return
*/
@@ -202,6 +323,12 @@ public function test_render_consent_field_implicit_type() {
$this->assertStringContainsString( 'value=\'Yes\'', $html );
$this->assertStringContainsString( 'consent-implicit', $html );
$this->assertStringContainsString( 'By submitting this form, you agree to our terms.', $html );
+
+ $processor = new \WP_HTML_Tag_Processor( $html );
+ $this->assertTrue( $processor->next_tag( array( 'tag_name' => 'DIV' ) ) );
+ $context = json_decode( (string) $processor->get_attribute( 'data-wp-context' ), true );
+ $this->assertIsArray( $context );
+ $this->assertSame( 'Yes', $context['fieldValue'] );
}
/**
@@ -245,6 +372,98 @@ public function test_render_consent_field_default_implicit() {
$this->assertStringContainsString( 'consent-implicit', $html );
}
+ /**
+ * Hidden fields can drive conditional logic, so the browser store must register them.
+ */
+ public function test_render_hidden_field_registers_its_value_with_interactivity() {
+ $field = $this->get_new_field_instance(
+ array(
+ 'type' => 'hidden',
+ 'id' => 'campaign',
+ 'default' => 'summer',
+ )
+ );
+
+ $processor = new \WP_HTML_Tag_Processor( $field->render() );
+ $this->assertTrue( $processor->next_tag( array( 'tag_name' => 'INPUT' ) ) );
+ $this->assertSame( 'campaign', $processor->get_attribute( 'data-jp-field-id' ) );
+ $this->assertSame( 'callbacks.initializeField', $processor->get_attribute( 'data-wp-init' ) );
+
+ $context = json_decode( (string) $processor->get_attribute( 'data-wp-context' ), true );
+ $this->assertIsArray( $context );
+ $this->assertSame( 'hidden', $context['fieldType'] );
+ $this->assertSame( 'summer', $context['fieldValue'] );
+ }
+
+ /**
+ * Hidden field rendering and server-side visibility share one filtered value.
+ */
+ public function test_hidden_field_value_filter_runs_once_for_render_and_conditional_logic() {
+ $filter_calls = 0;
+ $filter = static function () use ( &$filter_calls ) {
+ ++$filter_calls;
+ return 'filtered-' . $filter_calls;
+ };
+ add_filter( 'jetpack_forms_hidden_field_value', $filter );
+
+ $field = $this->get_new_field_instance(
+ array(
+ 'type' => 'hidden',
+ 'id' => 'campaign',
+ 'default' => 'summer',
+ )
+ );
+
+ try {
+ $html = $field->render();
+ $this->assertSame( 'filtered-1', $field->get_conditional_logic_value() );
+ $this->assertStringContainsString( "value='filtered-1'", $html );
+ $this->assertSame( 1, $filter_calls );
+ } finally {
+ remove_filter( 'jetpack_forms_hidden_field_value', $filter );
+ }
+ }
+
+ /**
+ * A submitted hidden value was filtered before it was rendered into the browser.
+ */
+ public function test_submitted_hidden_field_value_is_not_filtered_again() {
+ $filter_calls = 0;
+ $filter = static function ( $value ) use ( &$filter_calls ) {
+ ++$filter_calls;
+ return 'prefix-' . $value;
+ };
+ add_filter( 'jetpack_forms_hidden_field_value', $filter );
+
+ $form = new Contact_Form( array( 'id' => 'submitted-form' ) );
+ $field = new Contact_Form_Field(
+ array(
+ 'type' => 'hidden',
+ 'id' => 'campaign',
+ 'default' => 'summer',
+ ),
+ '',
+ $form
+ );
+
+ try {
+ $this->assertStringContainsString( "value='prefix-summer'", $field->render() );
+
+ $_POST['action'] = 'grunion-contact-form';
+ $_POST['contact-form-id'] = $form->get_attribute( 'id' );
+ $_POST['contact-form-hash'] = $form->hash;
+ $_POST['campaign'] = 'prefix-summer';
+
+ $this->assertSame( 'prefix-summer', $field->get_conditional_logic_value() );
+ $submission_html = $field->render();
+ $this->assertStringContainsString( "value='prefix-summer'", $submission_html );
+ $this->assertStringNotContainsString( 'prefix-prefix-summer', $submission_html );
+ $this->assertSame( 1, $filter_calls );
+ } finally {
+ remove_filter( 'jetpack_forms_hidden_field_value', $filter );
+ }
+ }
+
/**
* A grouped field whose legend label is fully hidden via block visibility
* must render no