diff --git a/projects/packages/forms/changelog/try-conditional-form-fields b/projects/packages/forms/changelog/try-conditional-form-fields new file mode 100644 index 000000000000..e34e8b0f8285 --- /dev/null +++ b/projects/packages/forms/changelog/try-conditional-form-fields @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Add conditional logic to form fields, so any field can be shown or hidden based on another field's answer. Disabled by default while in testing; enable it with the forms-conditional-logic feature flag. diff --git a/projects/packages/forms/composer.json b/projects/packages/forms/composer.json index ba0957e437d8..d16a76164571 100644 --- a/projects/packages/forms/composer.json +++ b/projects/packages/forms/composer.json @@ -11,6 +11,7 @@ "automattic/jetpack-connection": "@dev", "automattic/jetpack-device-detection": "@dev", "automattic/jetpack-external-connections": "@dev", + "automattic/jetpack-feature-flags": "@dev", "automattic/jetpack-jwt": "@dev", "automattic/jetpack-logo": "@dev", "automattic/jetpack-menu-badges": "@dev", diff --git a/projects/packages/forms/src/blocks/contact-form/child-blocks.js b/projects/packages/forms/src/blocks/contact-form/child-blocks.js index fbfcf976aa0e..f28912fc2f2c 100644 --- a/projects/packages/forms/src/blocks/contact-form/child-blocks.js +++ b/projects/packages/forms/src/blocks/contact-form/child-blocks.js @@ -1,4 +1,7 @@ import { hasFeatureFlag } from '@automattic/jetpack-shared-extension-utils'; +// Side-effect import: registers the editor.BlockEdit filter that adds the conditional-logic +// panel to every jetpack/field-* block. +import '../shared/conditional-logic/register.jsx'; import DeprecatedOptionCheckbox from '../deprecated/field-option-checkbox/index.js'; import DeprecatedOptionRadio from '../deprecated/field-option-radio/index.js'; import JetpackDropzone from '../dropzone/index.jsx'; diff --git a/projects/packages/forms/src/blocks/contact-form/class-contact-form-block.php b/projects/packages/forms/src/blocks/contact-form/class-contact-form-block.php index 52732f2276f1..f7ed1eddb227 100644 --- a/projects/packages/forms/src/blocks/contact-form/class-contact-form-block.php +++ b/projects/packages/forms/src/blocks/contact-form/class-contact-form-block.php @@ -204,6 +204,10 @@ public static function register_feature( $features ) { $features['multistep-form'] = Current_Plan::supports( 'multistep-form' ); $features['form-webhooks'] = Current_Plan::supports( 'form-webhooks' ); + // Bridges the jetpack-feature-flags registration to the editor, so JS `hasFeatureFlag()` + // and PHP `Feature_Flags::is_enabled()` answer from one source under one name. + $features[ Jetpack_Forms::CONDITIONAL_LOGIC_FLAG ] = Jetpack_Forms::is_conditional_logic_enabled(); + return self::register_central_form_management_default( $features ); } diff --git a/projects/packages/forms/src/blocks/field-checkbox/index.js b/projects/packages/forms/src/blocks/field-checkbox/index.js index a9ab694a457f..ec7ceab0639a 100644 --- a/projects/packages/forms/src/blocks/field-checkbox/index.js +++ b/projects/packages/forms/src/blocks/field-checkbox/index.js @@ -11,6 +11,16 @@ export const form_editor = { category: 'choice', }; +/** + * Conditional logic: how this field's value is compared. + * + * Declared per block so the rule builder can offer the right operators and value + * input. A block that omits this simply gets no conditional-logic support. + */ +export const conditional_logic = { + type: 'boolean', +}; + export const settings = { ...defaultSettings, title: __( 'Checkbox', 'jetpack-forms' ), @@ -49,4 +59,5 @@ export default { name, settings, form_editor, + conditional_logic, }; diff --git a/projects/packages/forms/src/blocks/field-consent/index.js b/projects/packages/forms/src/blocks/field-consent/index.js index d077159babff..a7a7ec9e8eec 100644 --- a/projects/packages/forms/src/blocks/field-consent/index.js +++ b/projects/packages/forms/src/blocks/field-consent/index.js @@ -11,6 +11,16 @@ export const form_editor = { category: 'advanced', }; +/** + * Conditional logic: how this field's value is compared. + * + * Declared per block so the rule builder can offer the right operators and value + * input. A block that omits this simply gets no conditional-logic support. + */ +export const conditional_logic = { + type: 'boolean', +}; + export const settings = { ...defaultSettings, title: __( 'Terms consent', 'jetpack-forms' ), @@ -70,4 +80,5 @@ export default { name, settings, form_editor, + conditional_logic, }; diff --git a/projects/packages/forms/src/blocks/field-date/index.js b/projects/packages/forms/src/blocks/field-date/index.js index 60647caa5e31..efada8c5a72e 100644 --- a/projects/packages/forms/src/blocks/field-date/index.js +++ b/projects/packages/forms/src/blocks/field-date/index.js @@ -11,6 +11,16 @@ export const form_editor = { category: 'advanced', }; +/** + * Conditional logic: how this field's value is compared. + * + * Declared per block so the rule builder can offer the right operators and value + * input. A block that omits this simply gets no conditional-logic support. + */ +export const conditional_logic = { + type: 'date', +}; + export const settings = { ...defaultSettings, title: __( 'Date picker', 'jetpack-forms' ), @@ -56,4 +66,5 @@ export default { name, settings, form_editor, + conditional_logic, }; diff --git a/projects/packages/forms/src/blocks/field-email/index.jsx b/projects/packages/forms/src/blocks/field-email/index.jsx index a63ebdd25c49..f2c4b21bd43f 100644 --- a/projects/packages/forms/src/blocks/field-email/index.jsx +++ b/projects/packages/forms/src/blocks/field-email/index.jsx @@ -12,6 +12,16 @@ export const form_editor = { category: 'contact-info', }; +/** + * Conditional logic: how this field's value is compared. + * + * Declared per block so the rule builder can offer the right operators and value + * input. A block that omits this simply gets no conditional-logic support. + */ +export const conditional_logic = { + type: 'string', +}; + export const settings = { ...defaultSettings, title: __( 'Email field', 'jetpack-forms' ), @@ -45,4 +55,5 @@ export default { name, settings, form_editor, + conditional_logic, }; diff --git a/projects/packages/forms/src/blocks/field-file/index.jsx b/projects/packages/forms/src/blocks/field-file/index.jsx index f4b4e88ce085..c6a895a26778 100644 --- a/projects/packages/forms/src/blocks/field-file/index.jsx +++ b/projects/packages/forms/src/blocks/field-file/index.jsx @@ -11,6 +11,16 @@ export const form_editor = { category: 'advanced', }; +/** + * Conditional logic: how this field's value is compared. + * + * Declared per block so the rule builder can offer the right operators and value + * input. A block that omits this simply gets no conditional-logic support. + */ +export const conditional_logic = { + type: 'file', +}; + export const settings = { ...defaultSettings, title: __( 'File upload field', 'jetpack-forms' ), @@ -45,4 +55,5 @@ export default { name, settings, form_editor, + conditional_logic, }; diff --git a/projects/packages/forms/src/blocks/field-hidden/index.js b/projects/packages/forms/src/blocks/field-hidden/index.js index def5242a8092..52b51e0d5c51 100644 --- a/projects/packages/forms/src/blocks/field-hidden/index.js +++ b/projects/packages/forms/src/blocks/field-hidden/index.js @@ -10,6 +10,16 @@ export const form_editor = { category: 'advanced', }; +/** + * Conditional logic: how this field's value is compared. + * + * Declared per block so the rule builder can offer the right operators and value + * input. A block that omits this simply gets no conditional-logic support. + */ +export const conditional_logic = { + type: 'hidden', +}; + export const settings = { ...defaultSettings, title: __( 'Hidden field', 'jetpack-forms' ), @@ -39,4 +49,5 @@ export default { name, settings, form_editor, + conditional_logic, }; diff --git a/projects/packages/forms/src/blocks/field-image-select/index.tsx b/projects/packages/forms/src/blocks/field-image-select/index.tsx index 289572e9ede5..0382c9db8b06 100644 --- a/projects/packages/forms/src/blocks/field-image-select/index.tsx +++ b/projects/packages/forms/src/blocks/field-image-select/index.tsx @@ -16,6 +16,20 @@ export const form_editor = { category: 'choice', }; +/* + * Deliberately no `conditional_logic` declaration, so this block gets no panel. + * + * An image-select field submits a JSON document describing the choice + * (`{"perceived":"A","selected":"a","label":"Blue",...}`), not the label the rule builder + * offers as a value. All three evaluators would compare that document against `Blue` and + * never match, so `is` would be permanently false and `is not` permanently true: a rule + * would hide its field forever and the answer would then be dropped at storage. + * + * Comparing the decoded `label` in every evaluator is the real fix, but that is value-shape + * handling in exactly the place where the JS and PHP evaluators already drift. Offering no + * rule is better than offering one that cannot fire. + */ + export const settings = { ...defaultSettings, title: __( 'Image Select Field', 'jetpack-forms' ), diff --git a/projects/packages/forms/src/blocks/field-multiple-choice/index.js b/projects/packages/forms/src/blocks/field-multiple-choice/index.js index 8fa288752edd..f6f7cb4a4f40 100644 --- a/projects/packages/forms/src/blocks/field-multiple-choice/index.js +++ b/projects/packages/forms/src/blocks/field-multiple-choice/index.js @@ -11,6 +11,16 @@ export const form_editor = { category: 'choice', }; +/** + * Conditional logic: how this field's value is compared. + * + * Declared per block so the rule builder can offer the right operators and value + * input. A block that omits this simply gets no conditional-logic support. + */ +export const conditional_logic = { + type: 'multichoice', +}; + export const settings = { ...defaultSettings, title: __( 'Multiple choice (checkbox)', 'jetpack-forms' ), @@ -70,4 +80,5 @@ export default { name, settings, form_editor, + conditional_logic, }; diff --git a/projects/packages/forms/src/blocks/field-name/index.js b/projects/packages/forms/src/blocks/field-name/index.js index 9468f391f665..df83561b17c9 100644 --- a/projects/packages/forms/src/blocks/field-name/index.js +++ b/projects/packages/forms/src/blocks/field-name/index.js @@ -13,6 +13,16 @@ export const form_editor = { category: 'contact-info', }; +/** + * Conditional logic: how this field's value is compared. + * + * Declared per block so the rule builder can offer the right operators and value + * input. A block that omits this simply gets no conditional-logic support. + */ +export const conditional_logic = { + type: 'string', +}; + const transforms = { ...transformsSource, to: transformsSource.to.filter( transform => ! transform.blocks.includes( 'jetpack/' + name ) ), @@ -57,4 +67,5 @@ export default { name, settings, form_editor, + conditional_logic, }; diff --git a/projects/packages/forms/src/blocks/field-number/index.js b/projects/packages/forms/src/blocks/field-number/index.js index 7f01551d2433..dda8a8eb5633 100644 --- a/projects/packages/forms/src/blocks/field-number/index.js +++ b/projects/packages/forms/src/blocks/field-number/index.js @@ -11,6 +11,16 @@ export const form_editor = { category: 'basic', }; +/** + * Conditional logic: how this field's value is compared. + * + * Declared per block so the rule builder can offer the right operators and value + * input. A block that omits this simply gets no conditional-logic support. + */ +export const conditional_logic = { + type: 'number', +}; + export const settings = { ...defaultSettings, title: __( 'Number input field', 'jetpack-forms' ), @@ -41,4 +51,5 @@ export default { name, settings, form_editor, + conditional_logic, }; diff --git a/projects/packages/forms/src/blocks/field-rating/index.js b/projects/packages/forms/src/blocks/field-rating/index.js index a0f3650e838c..dc948aafd762 100644 --- a/projects/packages/forms/src/blocks/field-rating/index.js +++ b/projects/packages/forms/src/blocks/field-rating/index.js @@ -11,6 +11,16 @@ export const form_editor = { category: 'advanced', }; +/** + * Conditional logic: how this field's value is compared. + * + * Declared per block so the rule builder can offer the right operators and value + * input. A block that omits this simply gets no conditional-logic support. + */ +export const conditional_logic = { + type: 'rating', +}; + export const settings = { ...defaultSettings, title: __( 'Rating field', 'jetpack-forms' ), @@ -60,4 +70,4 @@ export const settings = { }, }; -export default { name, settings, form_editor }; +export default { name, settings, form_editor, conditional_logic }; diff --git a/projects/packages/forms/src/blocks/field-select/index.js b/projects/packages/forms/src/blocks/field-select/index.js index 07a478f6b330..d2e6ab2d362a 100644 --- a/projects/packages/forms/src/blocks/field-select/index.js +++ b/projects/packages/forms/src/blocks/field-select/index.js @@ -11,6 +11,16 @@ export const form_editor = { category: 'choice', }; +/** + * Conditional logic: how this field's value is compared. + * + * Declared per block so the rule builder can offer the right operators and value + * input. A block that omits this simply gets no conditional-logic support. + */ +export const conditional_logic = { + type: 'choice', +}; + export const settings = { ...defaultSettings, title: __( 'Dropdown field', 'jetpack-forms' ), @@ -62,4 +72,5 @@ export default { name, settings, form_editor, + conditional_logic, }; diff --git a/projects/packages/forms/src/blocks/field-single-choice/index.js b/projects/packages/forms/src/blocks/field-single-choice/index.js index a1c806a9a830..8ec64722d0b1 100644 --- a/projects/packages/forms/src/blocks/field-single-choice/index.js +++ b/projects/packages/forms/src/blocks/field-single-choice/index.js @@ -11,6 +11,16 @@ export const form_editor = { category: 'choice', }; +/** + * Conditional logic: how this field's value is compared. + * + * Declared per block so the rule builder can offer the right operators and value + * input. A block that omits this simply gets no conditional-logic support. + */ +export const conditional_logic = { + type: 'choice', +}; + export const settings = { ...defaultSettings, title: __( 'Single choice (radio)', 'jetpack-forms' ), @@ -68,4 +78,5 @@ export default { name, settings, form_editor, + conditional_logic, }; diff --git a/projects/packages/forms/src/blocks/field-slider/index.jsx b/projects/packages/forms/src/blocks/field-slider/index.jsx index 955e3b657af4..dccae5245347 100644 --- a/projects/packages/forms/src/blocks/field-slider/index.jsx +++ b/projects/packages/forms/src/blocks/field-slider/index.jsx @@ -10,6 +10,16 @@ export const form_editor = { category: 'advanced', }; +/** + * Conditional logic: how this field's value is compared. + * + * Declared per block so the rule builder can offer the right operators and value + * input. A block that omits this simply gets no conditional-logic support. + */ +export const conditional_logic = { + type: 'number', +}; + export const settings = { ...defaultSettings, title: __( 'Slider field', 'jetpack-forms' ), @@ -85,4 +95,5 @@ export default { name, settings, form_editor, + conditional_logic, }; diff --git a/projects/packages/forms/src/blocks/field-telephone/index.jsx b/projects/packages/forms/src/blocks/field-telephone/index.jsx index ab1363eb0cc7..337c1761e468 100644 --- a/projects/packages/forms/src/blocks/field-telephone/index.jsx +++ b/projects/packages/forms/src/blocks/field-telephone/index.jsx @@ -12,6 +12,16 @@ export const form_editor = { category: 'contact-info', }; +/** + * Conditional logic: how this field's value is compared. + * + * Declared per block so the rule builder can offer the right operators and value + * input. A block that omits this simply gets no conditional-logic support. + */ +export const conditional_logic = { + type: 'string', +}; + export const settings = { ...defaultSettings, title: __( 'Phone number field', 'jetpack-forms' ), @@ -74,4 +84,5 @@ export default { name, settings, form_editor, + conditional_logic, }; diff --git a/projects/packages/forms/src/blocks/field-text/edit.jsx b/projects/packages/forms/src/blocks/field-text/edit.jsx index b0e06c68c271..397cfddadf12 100644 --- a/projects/packages/forms/src/blocks/field-text/edit.jsx +++ b/projects/packages/forms/src/blocks/field-text/edit.jsx @@ -2,6 +2,15 @@ import { __ } from '@wordpress/i18n'; import JetpackField from '../shared/components/jetpack-field.jsx'; import useFormWrapper from '../shared/hooks/use-form-wrapper.js'; +/** + * Editor component for the jetpack/field-text block. + * + * The conditional-logic panel is not wired up here: an `editor.BlockEdit` filter adds it to + * every `jetpack/field-*` block, this one included. + * + * @param {object} props - Block editor props passed in by Gutenberg. + * @return {object} The text field editor markup. + */ export default function TextFieldEdit( props ) { useFormWrapper( props ); diff --git a/projects/packages/forms/src/blocks/field-text/index.js b/projects/packages/forms/src/blocks/field-text/index.js index a7e5a864c413..5e1cf330acc3 100644 --- a/projects/packages/forms/src/blocks/field-text/index.js +++ b/projects/packages/forms/src/blocks/field-text/index.js @@ -11,6 +11,16 @@ export const form_editor = { category: 'basic', }; +/** + * Conditional logic: how this field's value is compared. + * + * Declared per block so the rule builder can offer the right operators and value + * input. A block that omits this simply gets no conditional-logic support. + */ +export const conditional_logic = { + type: 'string', +}; + export const settings = { ...defaultSettings, title: __( 'Text input field', 'jetpack-forms' ), @@ -41,4 +51,5 @@ export default { name, settings, form_editor, + conditional_logic, }; diff --git a/projects/packages/forms/src/blocks/field-textarea/index.js b/projects/packages/forms/src/blocks/field-textarea/index.js index bd9f74d18c60..3f7ce54f4142 100644 --- a/projects/packages/forms/src/blocks/field-textarea/index.js +++ b/projects/packages/forms/src/blocks/field-textarea/index.js @@ -11,6 +11,16 @@ export const form_editor = { category: 'basic', }; +/** + * Conditional logic: how this field's value is compared. + * + * Declared per block so the rule builder can offer the right operators and value + * input. A block that omits this simply gets no conditional-logic support. + */ +export const conditional_logic = { + type: 'string', +}; + export const settings = { ...defaultSettings, title: __( 'Multi-line text field', 'jetpack-forms' ), @@ -46,4 +56,5 @@ export default { name, settings, form_editor, + conditional_logic, }; diff --git a/projects/packages/forms/src/blocks/field-time/index.js b/projects/packages/forms/src/blocks/field-time/index.js index a6ff7226c7da..59d85824c133 100644 --- a/projects/packages/forms/src/blocks/field-time/index.js +++ b/projects/packages/forms/src/blocks/field-time/index.js @@ -10,6 +10,16 @@ export const form_editor = { category: 'advanced', }; +/** + * Conditional logic: how this field's value is compared. + * + * Declared per block so the rule builder can offer the right operators and value + * input. A block that omits this simply gets no conditional-logic support. + */ +export const conditional_logic = { + type: 'time', +}; + export const settings = { ...defaultSettings, title: __( 'Time input field', 'jetpack-forms' ), @@ -39,4 +49,5 @@ export default { name, settings, form_editor, + conditional_logic, }; diff --git a/projects/packages/forms/src/blocks/field-url/index.jsx b/projects/packages/forms/src/blocks/field-url/index.jsx index bcf99d9afc25..fb270ed52ece 100644 --- a/projects/packages/forms/src/blocks/field-url/index.jsx +++ b/projects/packages/forms/src/blocks/field-url/index.jsx @@ -12,6 +12,16 @@ export const form_editor = { category: 'contact-info', }; +/** + * Conditional logic: how this field's value is compared. + * + * Declared per block so the rule builder can offer the right operators and value + * input. A block that omits this simply gets no conditional-logic support. + */ +export const conditional_logic = { + type: 'string', +}; + export const settings = { ...defaultSettings, title: __( 'Website field', 'jetpack-forms' ), @@ -50,4 +60,5 @@ export default { name, settings, form_editor, + conditional_logic, }; diff --git a/projects/packages/forms/src/blocks/shared/conditional-logic/components/panel.jsx b/projects/packages/forms/src/blocks/shared/conditional-logic/components/panel.jsx new file mode 100644 index 000000000000..f8c7a20b2c85 --- /dev/null +++ b/projects/packages/forms/src/blocks/shared/conditional-logic/components/panel.jsx @@ -0,0 +1,219 @@ +import { + BlockControls, + InspectorControls, + store as blockEditorStore, +} from '@wordpress/block-editor'; +import { Button, PanelBody, ToolbarButton, ToolbarGroup } from '@wordpress/components'; +import { useDispatch } from '@wordpress/data'; +import { useCallback, useEffect, useMemo, useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { seen, unseen } from '@wordpress/icons'; +import { Stack, Text } from '@wordpress/ui'; +import clsx from 'clsx'; +import { + countRules, + getPrimaryGroup, + normalizeLogic, + startsHidden, + withPrimaryGroupRules, +} from '../constants.js'; +import useSubjectFields from '../hooks/use-subject-fields.js'; +import { + describeRule, + getActiveConditions, + getSummaryHeading, + getSummaryText, +} from '../util/summary.js'; +import ConditionalLogicModal from './rules-modal.jsx'; +import '../editor.scss'; + +/** + * One condition in the inspector summary, which highlights its subject block on hover. + * + * The same outline the block list draws when you hover a row there, and the same store action + * behind it -- so pointing at "Phone (Dropdown field)" shows you which field that is on the + * canvas rather than leaving you to find it by name. + * + * @param {object} props - Component props. + * @param {object} props.rule - The rule to describe. + * @param {object} props.subject - The rule's subject field descriptor. + * @return {object} The rendered line. + */ +const ConditionSummaryLine = ( { rule, subject } ) => { + const { toggleBlockHighlight } = useDispatch( blockEditorStore ); + const clientId = subject?.clientId; + + const highlight = useCallback( + on => clientId && toggleBlockHighlight( clientId, on ), + [ clientId, toggleBlockHighlight ] + ); + + // Clear the highlight if this line goes away while the pointer is still over it -- + // selecting another block unmounts the panel, and mouseleave never arrives. + useEffect( () => () => highlight( false ), [ highlight ] ); + + return ( +
  • highlight( true ) } + onMouseLeave={ () => highlight( false ) } + onFocus={ () => highlight( true ) } + onBlur={ () => highlight( false ) } + > + { describeRule( rule, subject ) } +
  • + ); +}; + +/** + * 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. */ } +
      + { activeConditions.map( ( { rule, subject }, index ) => ( + + ) ) } +
    +
    + ) : ( + + { __( + '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 => ( + + { grouped[ group ].map( field => ( + + ) ) } + + ) ) } + + + ( { + 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 , but must move the label onto the
    as an diff --git a/projects/packages/forms/tests/php/contact-form/Feedback_Conditional_Logic_Test.php b/projects/packages/forms/tests/php/contact-form/Feedback_Conditional_Logic_Test.php new file mode 100644 index 000000000000..4403bba0cc42 --- /dev/null +++ b/projects/packages/forms/tests/php/contact-form/Feedback_Conditional_Logic_Test.php @@ -0,0 +1,370 @@ + 'cf-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', + 'conditionallogic' => $dependent_logic, + ), + '', + $form + ); + + $form->fields = array( + 'trigger' => $trigger, + 'dependent' => $dependent, + ); + + return $form; + } + + /** + * Extract the feedback-field keys that reference a given original field id. + * + * @param Feedback $feedback The feedback instance. + * @param string $original_id The field id set on the original Contact_Form_Field. + * + * @return Feedback_Field|null + */ + private function find_feedback_field( Feedback $feedback, string $original_id ) { + return $feedback->get_field_by_form_field_id( $original_id ); + } + + public function test_hidden_dependent_field_is_not_persisted() { + $form = $this->build_form_with_dependent_field( + array( + 'enabled' => true, + 'action' => 'show', + 'logicalOperator' => 'any', + 'groups' => array( + array( + 'logicalOperator' => 'all', + 'rules' => array( + array( + 'field' => 'trigger', + 'operator' => 'is', + 'value' => 'yes', + ), + ), + ), + ), + ) + ); + + $post_data = array( + 'trigger' => 'no', + 'dependent' => 'should-not-be-stored', + ); + + $feedback = $this->submit( $post_data, $form ); + + $this->assertNotNull( + $this->find_feedback_field( $feedback, 'trigger' ), + 'Trigger field should always be present.' + ); + $this->assertNull( + $this->find_feedback_field( $feedback, 'dependent' ), + 'Dependent field should be stripped when conditional logic says it is hidden.' + ); + } + + public function test_visible_dependent_field_is_persisted() { + $form = $this->build_form_with_dependent_field( + array( + 'enabled' => true, + 'action' => 'show', + 'logicalOperator' => 'any', + 'groups' => array( + array( + 'logicalOperator' => 'all', + 'rules' => array( + array( + 'field' => 'trigger', + 'operator' => 'is', + 'value' => 'yes', + ), + ), + ), + ), + ) + ); + + $post_data = array( + 'trigger' => 'yes', + 'dependent' => 'kept', + ); + + $feedback = $this->submit( $post_data, $form ); + + $dependent_field = $this->find_feedback_field( $feedback, 'dependent' ); + $this->assertNotNull( $dependent_field, 'Dependent field should persist when rule matches.' ); + $this->assertSame( 'kept', $dependent_field->get_value() ); + } + + public function test_disabled_logic_never_strips_fields() { + $form = $this->build_form_with_dependent_field( + array( + 'enabled' => false, + 'action' => 'show', + 'logicalOperator' => 'any', + 'groups' => array( + array( + 'logicalOperator' => 'all', + 'rules' => array( + array( + 'field' => 'trigger', + 'operator' => 'is', + 'value' => 'yes', + ), + ), + ), + ), + ) + ); + + $post_data = array( + 'trigger' => 'no', + 'dependent' => 'still-kept', + ); + + $feedback = $this->submit( $post_data, $form ); + + $dependent_field = $this->find_feedback_field( $feedback, 'dependent' ); + $this->assertNotNull( $dependent_field, 'Disabled logic must not strip the field.' ); + $this->assertSame( 'still-kept', $dependent_field->get_value() ); + } + + public function test_hide_action_strips_when_rule_matches() { + $form = $this->build_form_with_dependent_field( + array( + 'enabled' => true, + 'action' => 'hide', + 'logicalOperator' => 'any', + 'groups' => array( + array( + 'logicalOperator' => 'all', + 'rules' => array( + array( + 'field' => 'trigger', + 'operator' => 'is', + 'value' => 'secret', + ), + ), + ), + ), + ) + ); + + $post_data = array( + 'trigger' => 'secret', + 'dependent' => 'leak', + ); + + $feedback = $this->submit( $post_data, $form ); + + $this->assertNull( + $this->find_feedback_field( $feedback, 'dependent' ), + 'Hide-action should strip the value when its rule matches.' + ); + } + + /** + * A hidden field's answer must not keep a downstream field alive. + * + * A -> B -> C: when A stops matching, B hides. B may still carry a value from before the + * visitor changed A, and evaluating each field independently would let that stale value + * satisfy C's "is not empty" rule, storing an answer justified by one that was discarded. + */ + public function test_cascade_strips_the_whole_chain() { + $form = new Contact_Form( array( 'id' => 'cf-cascade' ) ); + + $rule = function ( $field, $operator, $value = '' ) { + return array( + 'enabled' => true, + 'action' => 'show', + 'logicalOperator' => 'all', + 'groups' => array( + array( + 'logicalOperator' => 'all', + 'rules' => array( + array( + 'field' => $field, + 'operator' => $operator, + 'value' => $value, + ), + ), + ), + ), + ); + }; + + $a = new Contact_Form_Field( + array( + 'id' => 'a', + 'type' => 'text', + 'label' => 'A', + ), + '', + $form + ); + $b = new Contact_Form_Field( + array( + 'id' => 'b', + 'type' => 'text', + 'label' => 'B', + 'conditionallogic' => $rule( 'a', 'is', 'Other' ), + ), + '', + $form + ); + $c = new Contact_Form_Field( + array( + 'id' => 'c', + 'type' => 'text', + 'label' => 'C', + 'conditionallogic' => $rule( 'b', 'is_not_empty' ), + ), + '', + $form + ); + + $form->fields = array( + 'a' => $a, + 'b' => $b, + 'c' => $c, + ); + + $feedback = $this->submit( + array( + 'a' => 'Something else', + 'b' => 'stale', + 'c' => 'should-not-be-stored', + ), + $form + ); + + $this->assertNotNull( $this->find_feedback_field( $feedback, 'a' ), 'A is unconditional.' ); + $this->assertNull( $this->find_feedback_field( $feedback, 'b' ), 'B is hidden by its own rule.' ); + $this->assertNull( + $this->find_feedback_field( $feedback, 'c' ), + 'C must not survive on the strength of a hidden field value.' + ); + } + + /** + * Storage and validation must agree about a prefilled field the visitor cleared. + * + * Validation resolves visibility from get_computed_field_value(): POST, then GET, then + * the field's default. Storage used to resolve again from POST alone. A trigger prefilled + * through a query argument and then cleared by the visitor posts nothing, so the two read + * opposite values -- the dependent field was validated as visible and required, and then + * had its answer dropped as hidden. + */ + public function test_storage_agrees_with_validation_about_a_cleared_prefilled_trigger() { + $form = $this->build_form_with_dependent_field( + array( + 'enabled' => true, + 'action' => 'show', + 'logicalOperator' => 'all', + 'groups' => array( + array( + 'logicalOperator' => 'all', + 'rules' => array( + array( + 'field' => 'trigger', + 'operator' => 'is', + 'value' => 'yes', + ), + ), + ), + ), + ) + ); + + // Prefilled in the link, cleared by the visitor: present in GET, absent from POST. + $_GET['trigger'] = 'yes'; + $_POST = array( 'dependent' => 'an answer' ); + + $feedback = Feedback::from_submission( array( 'dependent' => 'an answer' ), $form ); + + $visibility = $form->get_resolved_field_visibility(); + $stored = null !== $this->find_feedback_field( $feedback, 'dependent' ); + + $this->assertSame( + $visibility['dependent'] ?? true, + $stored, + 'Storage must keep exactly the fields the form resolved as visible.' + ); + + unset( $_GET['trigger'] ); + } +} diff --git a/projects/packages/forms/tests/php/service/Integration_Filtered_Fields_Test.php b/projects/packages/forms/tests/php/service/Integration_Filtered_Fields_Test.php new file mode 100644 index 000000000000..4b3e5df7ead9 --- /dev/null +++ b/projects/packages/forms/tests/php/service/Integration_Filtered_Fields_Test.php @@ -0,0 +1,111 @@ + 'integration-form' ) ); + + $email = new Contact_Form_Field( + array( + 'id' => 'email', + 'type' => 'email', + 'label' => 'Email', + ), + '', + $form + ); + $email->value = 'hidden@example.com'; + $name = new Contact_Form_Field( + array( + 'id' => 'firstname', + 'type' => 'text', + 'label' => 'First Name', + ), + '', + $form + ); + $name->value = 'Visible'; + $form->fields = array( $email, $name ); + + $method = new \ReflectionMethod( $integration, 'get_subscriber_data_from_fields' ); + if ( PHP_VERSION_ID < 80100 ) { + $method->setAccessible( true ); + } + + $this->assertSame( array(), $method->invoke( null, array( $name ) ) ); + } + + /** + * Integrations with legacy field extractors. + * + * @return array + */ + public static function integration_provider() { + return array( + 'MailPoet' => array( MailPoet_Integration::class ), + 'Hostinger Reach' => array( Hostinger_Reach_Integration::class ), + ); + } + + /** + * Unversioned JSON feedback uses the same structured field API as v2 and v3 entries. + */ + public function test_unversioned_json_feedback_is_structured() { + $post_id = wp_insert_post( + array( + 'post_type' => Feedback::POST_TYPE, + 'post_status' => 'publish', + 'post_content' => wp_json_encode( + array( + 'fields' => array( + array( + 'id' => 'email', + 'label' => 'Email', + 'type' => 'email', + 'value' => 'reader@example.com', + ), + ), + ), + JSON_UNESCAPED_SLASHES + ), + ) + ); + + Feedback::clear_cache(); + $feedback = Feedback::get( $post_id ); + + $this->assertInstanceOf( Feedback::class, $feedback ); + $this->assertTrue( $feedback->uses_structured_fields() ); + + wp_delete_post( $post_id, true ); + Feedback::clear_cache(); + } +} diff --git a/projects/packages/forms/tests/php/service/Post_To_Url_Test.php b/projects/packages/forms/tests/php/service/Post_To_Url_Test.php index ce3b7515beb7..0d90535dc998 100644 --- a/projects/packages/forms/tests/php/service/Post_To_Url_Test.php +++ b/projects/packages/forms/tests/php/service/Post_To_Url_Test.php @@ -8,6 +8,7 @@ namespace Automattic\Jetpack\Forms\Service; use Automattic\Jetpack\Forms\ContactForm\Contact_Form; +use Automattic\Jetpack\Forms\ContactForm\Contact_Form_Field; use PHPUnit\Framework\Attributes\CoversClass; use WorDBless\BaseTestCase; @@ -24,15 +25,47 @@ class Post_To_Url_Test extends BaseTestCase { * * @param Contact_Form $form The form instance. * @param array $entry_values The feedback entry values. + * @param array|null $fields The visible submitted fields. * @return array The gathered form data. */ - private function invoke_get_form_data( $form, $entry_values = array() ) { + private function invoke_get_form_data( $form, $entry_values = array(), $fields = null ) { $instance = Post_To_Url::init(); $method = new \ReflectionMethod( Post_To_Url::class, 'get_form_data' ); if ( PHP_VERSION_ID < 80100 ) { $method->setAccessible( true ); } - return $method->invoke( $instance, $form, $entry_values ); + return $method->invoke( $instance, $form, $fields ?? $form->fields, $entry_values ); + } + + /** + * Conditionally hidden fields are filtered before the integration hook runs. + */ + public function test_get_form_data_uses_the_filtered_field_collection() { + $form = $this->create_mock_form( array() ); + + $visible = new Contact_Form_Field( + array( + 'id' => 'visible', + 'type' => 'text', + ), + '', + $form + ); + $visible->value = 'keep'; + $hidden = new Contact_Form_Field( + array( + 'id' => 'hidden', + 'type' => 'text', + ), + '', + $form + ); + $hidden->value = 'drop'; + $form->fields = array( $visible, $hidden ); + + $data = $this->invoke_get_form_data( $form, array(), array( $visible ) ); + + $this->assertSame( array( 'visible' => 'keep' ), $data ); } /** diff --git a/projects/plugins/jetpack/changelog/try-conditional-form-fields b/projects/plugins/jetpack/changelog/try-conditional-form-fields new file mode 100644 index 000000000000..1b4d872d2ab2 --- /dev/null +++ b/projects/plugins/jetpack/changelog/try-conditional-form-fields @@ -0,0 +1,5 @@ +Significance: patch +Type: other +Comment: Update composer.lock. + + diff --git a/projects/plugins/jetpack/composer.lock b/projects/plugins/jetpack/composer.lock index 7ad3a311c7fc..b0b6d8b024b7 100644 --- a/projects/plugins/jetpack/composer.lock +++ b/projects/plugins/jetpack/composer.lock @@ -1635,13 +1635,71 @@ "relative": true } }, + { + "name": "automattic/jetpack-feature-flags", + "version": "dev-trunk", + "dist": { + "type": "path", + "url": "../../packages/feature-flags", + "reference": "9747cc6b1ec3ece04c835a901e1513e1442b8996" + }, + "require": { + "php": ">=7.2" + }, + "require-dev": { + "automattic/phpunit-select-config": "@dev", + "brain/monkey": "^2.6.2", + "yoast/phpunit-polyfills": "^4.0.0" + }, + "suggest": { + "automattic/jetpack-autoloader": "Allow for better interoperability with other plugins that use this package." + }, + "type": "jetpack-library", + "extra": { + "autotagger": true, + "mirror-repo": "Automattic/jetpack-feature-flags", + "changelogger": { + "link-template": "https://github.com/Automattic/jetpack-feature-flags/compare/v${old}...v${new}" + }, + "branch-alias": { + "dev-trunk": "0.1.x-dev" + }, + "textdomain": "jetpack-feature-flags", + "version-constants": { + "::PACKAGE_VERSION": "src/class-feature-flags.php" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "scripts": { + "phpunit": [ + "phpunit-select-config phpunit.#.xml.dist --colors=always" + ], + "test-php": [ + "@composer phpunit" + ], + "test-php-coverage": [ + "php -dpcov.directory=. ./vendor/bin/phpunit-select-config phpunit.#.xml.dist --coverage-php \"$COVERAGE_DIR/php.cov\"" + ] + }, + "license": [ + "GPL-2.0-or-later" + ], + "description": "Shared utilities for registering and checking lightweight Jetpack feature flags.", + "transport-options": { + "relative": true + } + }, { "name": "automattic/jetpack-forms", "version": "dev-trunk", "dist": { "type": "path", "url": "../../packages/forms", - "reference": "b277a5ada04615aee2dbe69d1321339ca8065a28" + "reference": "7dceb3e1f578bc96043f2b614a8eb495c3b40713" }, "require": { "automattic/jetpack-admin-ui": "@dev", @@ -1650,6 +1708,7 @@ "automattic/jetpack-connection": "@dev", "automattic/jetpack-device-detection": "@dev", "automattic/jetpack-external-connections": "@dev", + "automattic/jetpack-feature-flags": "@dev", "automattic/jetpack-jwt": "@dev", "automattic/jetpack-logo": "@dev", "automattic/jetpack-menu-badges": "@dev",