From 637de48af15cc9df723ab6da0dbf2a7a43766974 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Mon, 10 Aug 2026 15:16:34 -0700 Subject: [PATCH 01/27] Forms: add conditional logic to form fields Any field can be shown or hidden based on another field's answer. Disabled by default: the whole feature sits behind the `forms-conditional-logic` flag, registered with the jetpack-feature-flags package, while it is in testing. Five pieces, reviewable separately as #50976-#50980: - the shared vocabulary both sides speak -- the comparison behaviour each field type has, the operators it offers, the value input each operator needs -- plus a `conditional_logic` declaration on each field block, beside `form_editor` and outside the registered block settings so it cannot collide with core block metadata - the resolver, written once in JS and once in PHP, because the browser decides what the visitor sees while the server decides what it accepts, and the browser's answer cannot be trusted at submit time. Resolution is a fixed point, since a field's visibility can depend on a field that is itself conditional; a cycle fails open, because hiding a field the visitor cannot reveal is a dead end - the front-end runtime, which shows and hides fields as the visitor answers and skips hidden fields when validating - the server side: hidden fields are skipped by validation, so a required field the visitor cannot see can never block submission; initial visibility is resolved during render so a hidden field never flashes into view; and a hidden field's answer reaches nothing downstream - the editor panel, added by an `editor.BlockEdit` filter rather than per-block wiring, loaded through a lazy boundary so a site with the feature off never fetches it Rebased onto trunk by content rather than by replaying commits: the branch had 36 commits against 103 of drift, and the conflicts landed in intermediate states that later work removed outright, so replaying them would have produced commits that never existed in a working form. The tree here is byte-identical to the verified pre-rebase branch merged with trunk. --- .../changelog/try-conditional-form-fields | 4 + projects/packages/forms/composer.json | 1 + .../src/blocks/contact-form/child-blocks.js | 3 + .../contact-form/class-contact-form-block.php | 4 + .../forms/src/blocks/field-checkbox/index.js | 11 + .../forms/src/blocks/field-consent/index.js | 11 + .../forms/src/blocks/field-date/index.js | 11 + .../forms/src/blocks/field-email/index.jsx | 11 + .../forms/src/blocks/field-file/index.jsx | 11 + .../forms/src/blocks/field-hidden/index.js | 11 + .../src/blocks/field-image-select/index.tsx | 14 + .../src/blocks/field-multiple-choice/index.js | 11 + .../forms/src/blocks/field-name/index.js | 11 + .../forms/src/blocks/field-number/index.js | 11 + .../forms/src/blocks/field-rating/index.js | 12 +- .../forms/src/blocks/field-select/index.js | 11 + .../src/blocks/field-single-choice/index.js | 11 + .../forms/src/blocks/field-slider/index.jsx | 11 + .../src/blocks/field-telephone/index.jsx | 11 + .../forms/src/blocks/field-text/edit.jsx | 9 + .../forms/src/blocks/field-text/index.js | 11 + .../forms/src/blocks/field-textarea/index.js | 11 + .../forms/src/blocks/field-time/index.js | 11 + .../forms/src/blocks/field-url/index.jsx | 11 + .../conditional-logic/components/panel.jsx | 148 ++++ .../shared/conditional-logic/constants.js | 25 + .../controls/field-value/edit.jsx | 373 ++++++++++ .../conditional-logic/controls/index.js | 33 + .../shared/conditional-logic/editor.scss | 77 ++ .../hooks/use-subject-fields.js | 164 +++++ .../shared/conditional-logic/register.jsx | 107 +++ .../conditional-logic/util/block-types.js | 66 ++ .../shared/conditional-logic/util/evaluate.ts | 460 ++++++++++++ .../conditional-logic/util/field-options.ts | 97 +++ .../conditional-logic/util/field-types.ts | 181 +++++ .../conditional-logic/util/operator-labels.ts | 42 ++ .../forms/src/blocks/shared/settings/index.js | 11 + .../forms/src/class-jetpack-forms.php | 44 ++ .../contact-form/class-conditional-logic.php | 566 +++++++++++++++ .../contact-form/class-contact-form-field.php | 46 +- .../class-contact-form-plugin.php | 38 + .../src/contact-form/class-contact-form.php | 226 +++++- .../forms/src/contact-form/class-feedback.php | 81 ++- .../forms/src/contact-form/css/grunion.scss | 42 ++ .../modules/form/conditional-visibility.js | 96 +++ .../packages/forms/src/modules/form/view.js | 101 ++- .../fixtures/conditional-logic-behaviour.json | 224 ++++++ .../conditional-logic/block-names.test.js | 105 +++ .../shared/conditional-logic/evaluate.test.js | 405 +++++++++++ .../conditional-logic/field-options.test.js | 126 ++++ .../conditional-logic/field-types.test.js | 78 ++ .../conditional-logic/operator-labels.test.js | 30 + .../shared/conditional-logic/panel.test.jsx | 368 ++++++++++ .../shared/conditional-logic/register.test.js | 109 +++ .../with-conditional-logic.test.jsx | 91 +++ .../form/conditional-visibility.test.js | 226 ++++++ .../Conditional_Logic_Behaviour_Test.php | 94 +++ .../Conditional_Logic_Feature_Flag_Test.php | 201 ++++++ .../Conditional_Logic_Initial_Render_Test.php | 268 +++++++ .../Conditional_Logic_Parity_Test.php | 118 +++ .../Conditional_Logic_Required_Field_Test.php | 168 +++++ .../contact-form/Conditional_Logic_Test.php | 674 ++++++++++++++++++ .../Conditional_Logic_Validation_Test.php | 193 +++++ .../Feedback_Conditional_Logic_Test.php | 364 ++++++++++ .../changelog/try-conditional-form-fields | 5 + projects/plugins/jetpack/composer.lock | 61 +- 66 files changed, 7150 insertions(+), 16 deletions(-) create mode 100644 projects/packages/forms/changelog/try-conditional-form-fields create mode 100644 projects/packages/forms/src/blocks/shared/conditional-logic/components/panel.jsx create mode 100644 projects/packages/forms/src/blocks/shared/conditional-logic/constants.js create mode 100644 projects/packages/forms/src/blocks/shared/conditional-logic/controls/field-value/edit.jsx create mode 100644 projects/packages/forms/src/blocks/shared/conditional-logic/controls/index.js create mode 100644 projects/packages/forms/src/blocks/shared/conditional-logic/editor.scss create mode 100644 projects/packages/forms/src/blocks/shared/conditional-logic/hooks/use-subject-fields.js create mode 100644 projects/packages/forms/src/blocks/shared/conditional-logic/register.jsx create mode 100644 projects/packages/forms/src/blocks/shared/conditional-logic/util/block-types.js create mode 100644 projects/packages/forms/src/blocks/shared/conditional-logic/util/evaluate.ts create mode 100644 projects/packages/forms/src/blocks/shared/conditional-logic/util/field-options.ts create mode 100644 projects/packages/forms/src/blocks/shared/conditional-logic/util/field-types.ts create mode 100644 projects/packages/forms/src/blocks/shared/conditional-logic/util/operator-labels.ts create mode 100644 projects/packages/forms/src/contact-form/class-conditional-logic.php create mode 100644 projects/packages/forms/src/modules/form/conditional-visibility.js create mode 100644 projects/packages/forms/tests/fixtures/conditional-logic-behaviour.json create mode 100644 projects/packages/forms/tests/js/blocks/shared/conditional-logic/block-names.test.js create mode 100644 projects/packages/forms/tests/js/blocks/shared/conditional-logic/evaluate.test.js create mode 100644 projects/packages/forms/tests/js/blocks/shared/conditional-logic/field-options.test.js create mode 100644 projects/packages/forms/tests/js/blocks/shared/conditional-logic/field-types.test.js create mode 100644 projects/packages/forms/tests/js/blocks/shared/conditional-logic/operator-labels.test.js create mode 100644 projects/packages/forms/tests/js/blocks/shared/conditional-logic/panel.test.jsx create mode 100644 projects/packages/forms/tests/js/blocks/shared/conditional-logic/register.test.js create mode 100644 projects/packages/forms/tests/js/blocks/shared/conditional-logic/with-conditional-logic.test.jsx create mode 100644 projects/packages/forms/tests/js/modules/form/conditional-visibility.test.js create mode 100644 projects/packages/forms/tests/php/contact-form/Conditional_Logic_Behaviour_Test.php create mode 100644 projects/packages/forms/tests/php/contact-form/Conditional_Logic_Feature_Flag_Test.php create mode 100644 projects/packages/forms/tests/php/contact-form/Conditional_Logic_Initial_Render_Test.php create mode 100644 projects/packages/forms/tests/php/contact-form/Conditional_Logic_Parity_Test.php create mode 100644 projects/packages/forms/tests/php/contact-form/Conditional_Logic_Required_Field_Test.php create mode 100644 projects/packages/forms/tests/php/contact-form/Conditional_Logic_Test.php create mode 100644 projects/packages/forms/tests/php/contact-form/Conditional_Logic_Validation_Test.php create mode 100644 projects/packages/forms/tests/php/contact-form/Feedback_Conditional_Logic_Test.php create mode 100644 projects/plugins/jetpack/changelog/try-conditional-form-fields 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..b297a8a7985b 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: 'number', +}; + 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..69a8fb2792b7 --- /dev/null +++ b/projects/packages/forms/src/blocks/shared/conditional-logic/components/panel.jsx @@ -0,0 +1,148 @@ +import { InspectorControls } from '@wordpress/block-editor'; +import { PanelBody, SelectControl } from '@wordpress/components'; +import { useCallback, useMemo } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { Stack, Text } from '@wordpress/ui'; +import { normalizeLogic } from '../constants.js'; +import { CONTROLS } from '../controls/index.js'; +import useSubjectFields from '../hooks/use-subject-fields.js'; +import '../editor.scss'; + +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' ) }, +]; + +/** + * Count the rules stored across every control. + * + * @param {object} controls - The `controls` map from the attribute. + * @return {number} Total rule count. + */ +const countRules = controls => + Object.values( controls || {} ).reduce( + ( total, control ) => total + ( Array.isArray( control?.rules ) ? control.rules.length : 0 ), + 0 + ); + +/** + * The "Conditional logic" inspector panel, injected into every field block. + * + * @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 logic = useMemo( + () => normalizeLogic( attributes.conditionalLogic ), + [ attributes.conditionalLogic ] + ); + + const fields = useSubjectFields( clientId ); + + const updateLogic = useCallback( + next => setAttributes( { conditionalLogic: next } ), + [ setAttributes ] + ); + + const handleActionChange = useCallback( + action => updateLogic( { ...logic, action } ), + [ logic, updateLogic ] + ); + + const handleMatchChange = useCallback( + logicalOperator => updateLogic( { ...logic, logicalOperator } ), + [ logic, updateLogic ] + ); + + /** + * Store a control's config, keeping `enabled` in step with whether any rule exists. + * + * Deriving it here rather than exposing a toggle means a field only carries conditional + * logic once it actually has a condition, so untouched fields add nothing to the page. + * + * @param {string} slug - The control slug. + * @param {object} next - The control's next config. + */ + const handleControlChange = useCallback( + ( slug, next ) => { + const controls = { ...logic.controls, [ slug ]: next }; + updateLogic( { ...logic, controls, enabled: countRules( controls ) > 0 } ); + }, + [ logic, updateLogic ] + ); + + const hasConditions = countRules( logic.controls ) > 0; + + return ( + + + { hasConditions ? ( + <> + + + + + + { __( 'of the following conditions are met:', 'jetpack-forms' ) } + + + ) : ( + + { __( + 'Show or hide this field based on the answer to another field.', + 'jetpack-forms' + ) } + + ) } + + { CONTROLS.map( control => { + const { Edit, slug } = control; + return ( + handleControlChange( slug, next ) } + /> + ); + } ) } + + + ); +}; + +export default ConditionalLogicPanel; 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..cb8f71046e4e --- /dev/null +++ b/projects/packages/forms/src/blocks/shared/conditional-logic/constants.js @@ -0,0 +1,25 @@ +/** + * 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', + logicalOperator: 'any', + controls: {}, +}; + +/** + * Merge a stored attribute over the defaults. + * + * @param {object} stored - The block's `conditionalLogic` attribute, possibly undefined. + * @return {object} A complete logic object. + */ +export const normalizeLogic = stored => ( { + ...DEFAULT_LOGIC, + ...( stored || {} ), + controls: { ...( stored?.controls || {} ) }, +} ); 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..5ddb36306267 --- /dev/null +++ b/projects/packages/forms/src/blocks/shared/conditional-logic/controls/field-value/edit.jsx @@ -0,0 +1,373 @@ +import { Notice, SelectControl, TextControl } from '@wordpress/components'; +import { useCallback, useMemo } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; +import { closeSmall, plus } from '@wordpress/icons'; +import { Button, IconButton, Stack, Text } from '@wordpress/ui'; +import { useEnsureFieldId } from '../../hooks/use-subject-fields.js'; +import { + OPERATORS, + getOperatorsForTypeKey, + getValueInputForTypeKey, + operatorNeedsValue, +} from '../../util/field-types.ts'; +import { getOperatorLabel } from '../../util/operator-labels.ts'; + +/** + * 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 }`; + +/** + * Dropdown text for a subject field. + * + * The field type is appended so an author can tell entries apart when the labels are + * unhelpful — two fields both reading "Untitled field", or several sharing a label. + * + * @param {object} field - Subject field descriptor. + * @return {string} Text to show in the dropdown. + */ +const optionLabel = field => + field.typeLabel + ? sprintf( + /* translators: 1: form field label, 2: the field's type, e.g. "Dropdown field" */ + __( '%1$s (%2$s)', 'jetpack-forms' ), + field.label, + field.typeLabel + ) + : field.label; + +/** + * 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 {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, onChange, onRemove } ) => { + 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' ); + + // 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 ( +
+ + + { sprintf( + /* translators: %d: condition number, starting at 1 */ + __( 'Condition %d', 'jetpack-forms' ), + index + 1 + ) } + + + + + { missingSubject && ( + + { __( + 'The referenced field no longer exists. Pick another field or remove this condition.', + 'jetpack-forms' + ) } + + ) } + + + + + { 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 {object} props.value - This control's stored config, `{ rules }`. + * @param {Function} props.onChange - Called with the control's next config. + * @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 FieldValueControl = ( { value, onChange, fields, ownFieldId } ) => { + const rules = useMemo( () => ( Array.isArray( value?.rules ) ? value.rules : [] ), [ value ] ); + + const updateRule = useCallback( + ( index, patch ) => { + onChange( { + ...value, + rules: rules.map( ( rule, i ) => ( i === index ? { ...rule, ...patch } : rule ) ), + } ); + }, + [ onChange, rules, value ] + ); + + const removeRule = useCallback( + index => { + onChange( { ...value, rules: rules.filter( ( _, i ) => i !== index ) } ); + }, + [ onChange, rules, value ] + ); + + // 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". + const addRule = useCallback( () => { + onChange( { + ...value, + rules: [ ...rules, { field: '', operator: OPERATORS.IS, value: '' } ], + } ); + }, [ onChange, rules, value ] ); + + if ( ! fields.length ) { + return ( + + { __( 'Add another field to this form to use as a condition.', 'jetpack-forms' ) } + + ); + } + + return ( + + { rules.map( ( rule, index ) => ( + + ) ) } + + { /* The single entry point for adding conditions. When further condition types + land (query string, user role, date and time) this becomes the menu that + offers the choice, so it stays the panel's one primary action. */ } + + + ); +}; + +export default FieldValueControl; diff --git a/projects/packages/forms/src/blocks/shared/conditional-logic/controls/index.js b/projects/packages/forms/src/blocks/shared/conditional-logic/controls/index.js new file mode 100644 index 000000000000..9045b6acfd1c --- /dev/null +++ b/projects/packages/forms/src/blocks/shared/conditional-logic/controls/index.js @@ -0,0 +1,33 @@ +import { __ } from '@wordpress/i18n'; +import FieldValueControl from './field-value/edit.jsx'; + +/** + * Registry of condition types offered by the "+" menu. + * + * Phase 1 registers only Field Value. Static condition types — query string, user role, date + * and time — are deliberately absent: they are decided once at render, but a submission does + * not carry the request context that produced it, so PHP cannot re-derive them at submit time + * without a signed render-time payload. Adding one here is the only change the panel needs. + * + * Each entry: + * - slug: key under `conditionalLogic.controls` + * - label: shown in the "+" menu and as the section heading + * - defaultValue: config stored when the control is switched on + * - Edit: component rendering the control's body + */ +export const CONTROLS = [ + { + slug: 'fieldValue', + label: __( 'Field value', 'jetpack-forms' ), + defaultValue: { rules: [] }, + Edit: FieldValueControl, + }, +]; + +/** + * Look up a control definition by slug. + * + * @param {string} slug - The control slug. + * @return {object|undefined} The control definition. + */ +export const getControl = slug => CONTROLS.find( control => control.slug === slug ); 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..aaba99dd539b --- /dev/null +++ b/projects/packages/forms/src/blocks/shared/conditional-logic/editor.scss @@ -0,0 +1,77 @@ +/** + * Conditional logic inspector panel. + * + * Layout and typography come from the design system: + * Stack handles the flex arrangement and spacing, Text + * handles the prose. What is left here is what those + * components do not express — the card chrome and the + * proportional split of the two summary selectors — and + * it uses wpds tokens rather than literal values. + */ + +.jetpack-contact-form__conditional-logic { + + .jetpack-contact-form__conditional-logic-intro, + .jetpack-contact-form__conditional-logic-hint { + display: block; + color: var(--wpds-color-foreground-content-neutral-weak); + } + + .jetpack-contact-form__conditional-logic-intro { + margin-block-end: var(--wpds-dimension-gap-lg); + } + + .jetpack-contact-form__conditional-logic-hint { + margin-block: var(--wpds-dimension-gap-sm) var(--wpds-dimension-gap-lg); + } + + // Show/hide carries the longer label, so it gets the + // extra room instead of splitting the row evenly. + .jetpack-contact-form__conditional-logic-summary { + + // Both selectors pass __nextHasNoMarginBottom, but + // BaseControl still emits a bottom margin here, which + // left the two boxes visibly out of line. Dropping it + // is what makes the row sit flush. + .components-base-control, + .components-base-control__field { + margin-block-end: 0; + } + + > *:first-child { + flex: 3 1 0; + min-width: 0; + } + + > *:last-child { + flex: 2 1 0; + min-width: 0; + } + } + + // One card per condition, so several conditions read + // as a list rather than an undifferentiated stack of + // selects. + .jetpack-contact-form__conditional-logic-rule { + padding: var(--wpds-dimension-padding-md); + border: + var(--wpds-border-width-xs) solid + var(--wpds-color-stroke-surface-neutral-weak); + border-radius: var(--wpds-border-radius-sm); + } + + .jetpack-contact-form__conditional-logic-rule-header { + margin-block-end: var(--wpds-dimension-gap-sm); + } + + .jetpack-contact-form__conditional-logic-rule-title { + color: var(--wpds-color-foreground-content-neutral-weak); + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .jetpack-contact-form__conditional-logic-add { + justify-content: center; + width: 100%; + } +} 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..0874939b35d7 --- /dev/null +++ b/projects/packages/forms/src/blocks/shared/conditional-logic/util/evaluate.ts @@ -0,0 +1,460 @@ +/* + * 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; +}; + +export type ConditionalLogic = { + enabled?: boolean; + action?: 'show' | 'hide'; + logicalOperator?: 'any' | 'all'; + controls?: { + fieldValue?: { rules?: Rule[] }; + }; +}; + +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. + */ +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 expected = operatorNeedsValue( operator ) ? rule.value ?? '' : ''; + + 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 ) { + const pair = toNumericPair( actual, 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 rules = logic.controls?.fieldValue?.rules; + if ( ! Array.isArray( rules ) || 0 === rules.length ) { + return true; + } + + const outcomes: boolean[] = []; + rules.forEach( rule => { + if ( ! rule || ! rule.field || ! rule.operator ) { + return; + } + 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 ); + } + } ); + + if ( 0 === outcomes.length ) { + return true; + } + + const matched = + 'all' === logic.logicalOperator ? outcomes.every( Boolean ) : outcomes.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-options.ts b/projects/packages/forms/src/blocks/shared/conditional-logic/util/field-options.ts new file mode 100644 index 000000000000..5d399ef71fcf --- /dev/null +++ b/projects/packages/forms/src/blocks/shared/conditional-logic/util/field-options.ts @@ -0,0 +1,97 @@ +/* + * 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 []; + } + + 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..869c39d463fd --- /dev/null +++ b/projects/packages/forms/src/blocks/shared/conditional-logic/util/field-types.ts @@ -0,0 +1,181 @@ +/* + * 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 slider and a rating both compare numerically, a select and a radio group both + * compare against a fixed option list. + */ +export type TypeKey = + | 'string' + | 'choice' + | 'multichoice' + | 'number' + | 'date' + | 'time' + | 'boolean' + | 'hidden' + | 'file'; + +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: 'number', + 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, + ], + 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', + 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/settings/index.js b/projects/packages/forms/src/blocks/shared/settings/index.js index e597ce2add04..623f553fdbb6 100644 --- a/projects/packages/forms/src/blocks/shared/settings/index.js +++ b/projects/packages/forms/src/blocks/shared/settings/index.js @@ -20,6 +20,17 @@ export default { type: 'boolean', default: true, }, + conditionalLogic: { + type: 'object', + default: { + enabled: false, + action: 'show', + logicalOperator: 'any', + // Keyed by control slug so further condition types (query string, user role, + // date and time) become sibling keys rather than a reshape. + controls: {}, + }, + }, }, 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..114bce0a9475 --- /dev/null +++ b/projects/packages/forms/src/contact-form/class-conditional-logic.php @@ -0,0 +1,566 @@ + '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' => 'number', + '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; + } + + $rules = array(); + if ( isset( $logic['controls']['fieldValue']['rules'] ) && is_array( $logic['controls']['fieldValue']['rules'] ) ) { + $rules = $logic['controls']['fieldValue']['rules']; + } + + if ( empty( $rules ) ) { + return true; + } + + $outcomes = array(); + foreach ( $rules as $rule ) { + if ( ! is_array( $rule ) || empty( $rule['field'] ) || empty( $rule['operator'] ) ) { + 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; + } + } + + if ( empty( $outcomes ) ) { + return true; + } + + $logical_operator = $logic['logicalOperator'] ?? 'any'; + + if ( 'all' === $logical_operator ) { + $matched = ! in_array( false, $outcomes, true ); + } else { + $matched = in_array( true, $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. + * + * @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']; + $expected = ''; + if ( ! in_array( $operator, self::OPERATORS_WITHOUT_VALUE, true ) && isset( $rule['value'] ) ) { + $expected = $rule['value']; + } + + 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 ) { + $pair = self::to_numeric_pair( $actual, $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; + } + + /** + * 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`. + * + * @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`. + * + * @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..3543cf9b12d2 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 @@ -203,11 +203,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 +345,20 @@ public function has_value() { return ! empty( trim( $field_value ) ); } + /** + * Validates the form input + */ + /** + * 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 */ @@ -2927,8 +2950,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 +2979,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..d224e2a4eba2 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,61 @@ 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(); + } + + /** + * 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 +1586,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 ) { @@ -2600,7 +2666,23 @@ public static function parse_contact_field( $attributes, $content, $block = null isset( $_POST['contact-form-hash'] ) && is_string( $_POST['contact-form-hash'] ) && hash_equals( $form->hash, wp_unslash( $_POST['contact-form-hash'] ) ) ) { // 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 +3189,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 +3208,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 +3870,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 +3897,121 @@ 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_computed_field_value( + $field->get_attribute( 'type' ), + $field->get_attribute( 'id' ) + ); + } + + 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..cbcd325a9692 100644 --- a/projects/packages/forms/src/contact-form/class-feedback.php +++ b/projects/packages/forms/src/contact-form/class-feedback.php @@ -507,6 +507,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 +552,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. * @@ -2202,16 +2237,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 +2300,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/tests/fixtures/conditional-logic-behaviour.json b/projects/packages/forms/tests/fixtures/conditional-logic-behaviour.json new file mode 100644 index 000000000000..e97425c0dbf3 --- /dev/null +++ b/projects/packages/forms/tests/fixtures/conditional-logic-behaviour.json @@ -0,0 +1,224 @@ +{ + "_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 + } + ] +} 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..0fb9bf661f83 --- /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': 'number', + '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/evaluate.test.js b/projects/packages/forms/tests/js/blocks/shared/conditional-logic/evaluate.test.js new file mode 100644 index 000000000000..94ad6b3a932b --- /dev/null +++ b/projects/packages/forms/tests/js/blocks/shared/conditional-logic/evaluate.test.js @@ -0,0 +1,405 @@ +import { readFileSync } from 'fs'; +import path from 'path'; +import { + evaluateLogic, + resolveVisibility, +} from '../../../../../src/blocks/shared/conditional-logic/util/evaluate'; + +const logic = ( rules, extra = {} ) => ( { + enabled: true, + action: 'show', + logicalOperator: 'all', + controls: { fieldValue: { rules } }, + ...extra, +} ); + +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', + controls: { + fieldValue: { + 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', + controls: { + fieldValue: { + 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 ); + } + ); +} ); 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..364caa585706 --- /dev/null +++ b/projects/packages/forms/tests/js/blocks/shared/conditional-logic/field-options.test.js @@ -0,0 +1,126 @@ +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' } ] ); + } ); +} ); 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..f6d444806bc3 --- /dev/null +++ b/projects/packages/forms/tests/js/blocks/shared/conditional-logic/field-types.test.js @@ -0,0 +1,78 @@ +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' ], + [ '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', + ], + 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..4a9d7f50676c --- /dev/null +++ b/projects/packages/forms/tests/js/blocks/shared/conditional-logic/panel.test.jsx @@ -0,0 +1,368 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +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, + }, +]; + +await jest.unstable_mockModule( '@wordpress/block-editor', () => ( { + InspectorControls: ( { children } ) =>
{ children }
, +} ) ); + +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', + controls: {}, +}; + +const withRules = ( rules, extra = {} ) => ( { + enabled: true, + action: 'show', + logicalOperator: 'all', + controls: { fieldValue: { rules } }, + ...extra, +} ); + +const setup = async ( conditionalLogic = DEFAULT_ATTRIBUTE ) => { + 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' } ) ); + + return { setAttributes, container }; +}; + +const optionValues = select => + within( select ) + .getAllByRole( 'option' ) + .map( o => o.value ); + +describe( 'ConditionalLogicPanel', () => { + it( 'renders the panel title', async () => { + await setup(); + expect( screen.getByText( 'Conditional logic' ) ).toBeInTheDocument(); + } ); + + it( 'shows the Add condition button with no conditions configured', 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. + it( 'enables logic when the first condition is added', async () => { + const { setAttributes } = await setup(); + await userEvent.click( screen.getByRole( 'button', { name: /add condition/i } ) ); + + expect( setAttributes ).toHaveBeenCalledWith( { + conditionalLogic: expect.objectContaining( { + enabled: true, + controls: { fieldValue: { rules: [ { field: '', operator: 'is', value: '' } ] } }, + } ), + } ); + } ); + + 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, + controls: { fieldValue: { rules: [] } }, + } ), + } ); + } ); + + it( 'hides the action and match selectors until a condition exists', async () => { + await setup(); + expect( screen.queryByLabelText( 'Action' ) ).not.toBeInTheDocument(); + } ); + + it( 'shows the action and match selectors once a condition exists', async () => { + await setup( withRules( [ { field: 'name_1', operator: 'is', value: 'x' } ] ) ); + expect( screen.getByLabelText( 'Action' ) ).toBeInTheDocument(); + expect( screen.getByLabelText( 'When' ) ).toBeInTheDocument(); + } ); + + // The single-row arrangement itself is CSS; what this can verify is that both selectors + // render together and that the sentence they belong to follows them rather than being + // interleaved, which is what wrapped badly before. + it( 'renders both selectors above the conditions sentence', async () => { + const { container } = await setup( + withRules( [ { field: 'name_1', operator: 'is', value: 'x' } ] ) + ); + + expect( screen.getByLabelText( 'Action' ) ).toBeInTheDocument(); + expect( screen.getByLabelText( 'When' ) ).toBeInTheDocument(); + expect( + within( container ).getByText( 'of the following conditions are met:' ) + ).toBeInTheDocument(); + } ); + + it( 'phrases the match options to read on from the action', async () => { + await setup( withRules( [ { field: 'name_1', operator: 'is', value: 'x' } ] ) ); + + 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 () => { + // Scoped to the rendered container: Notice mirrors its text into an aria-live region + // that WordPress appends to document.body, which would match twice. + const { container } = await setup( + withRules( [ { field: 'deleted_1', operator: 'is', value: 'x' } ] ) + ); + expect( within( container ).getByText( /no longer exists/i ) ).toBeInTheDocument(); + } ); + + it( 'adds a condition with no subject chosen yet', async () => { + const { setAttributes } = await setup( withRules( [] ) ); + await userEvent.click( screen.getByRole( 'button', { name: 'Add condition' } ) ); + + expect( setAttributes ).toHaveBeenCalledWith( { + conditionalLogic: expect.objectContaining( { + controls: { + fieldValue: { rules: [ { field: '', operator: 'is', value: '' } ] }, + }, + } ), + } ); + } ); + + // 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( { + controls: { + fieldValue: { + rules: [ { field: 'untitled-field', operator: 'is', value: '' } ], + }, + }, + } ), + } ); + } ); + + 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( { + controls: { + fieldValue: { 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( { + controls: { + fieldValue: { 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/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..72423b1ef5d4 --- /dev/null +++ b/projects/packages/forms/tests/js/blocks/shared/conditional-logic/with-conditional-logic.test.jsx @@ -0,0 +1,91 @@ +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' } } ], +} ) ); + +await jest.unstable_mockModule( '@wordpress/block-editor', () => ( { + InspectorControls: ( { children } ) =>
{ children }
, +} ) ); + +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..df89ecd3d1c6 --- /dev/null +++ b/projects/packages/forms/tests/js/modules/form/conditional-visibility.test.js @@ -0,0 +1,226 @@ +import { + clearVisibilityMemo, + isFieldHiddenByLogic, + resolveFormVisibility, +} from '../../../../src/modules/form/conditional-visibility.js'; + +const showWhen = ( field, value ) => ( { + enabled: true, + action: 'show', + logicalOperator: 'all', + controls: { + fieldValue: { 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', + controls: { + fieldValue: { + 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..ce21d1de53b4 --- /dev/null +++ b/projects/packages/forms/tests/php/contact-form/Conditional_Logic_Behaviour_Test.php @@ -0,0 +1,94 @@ +}> + */ + public static function behaviour_cases(): array { + $path = __DIR__ . '/../../fixtures/conditional-logic-behaviour.json'; + $data = json_decode( (string) file_get_contents( $path ), true ); + + $cases = array(); + foreach ( $data['cases'] as $case ) { + $cases[ $case['name'] ] = array( $case ); + } + + return $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', + 'controls' => array( + 'fieldValue' => array( + '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() { + $data = json_decode( + (string) file_get_contents( __DIR__ . '/../../fixtures/conditional-logic-behaviour.json' ), + true + ); + $types = array_unique( array_column( $data['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..d31090f71012 --- /dev/null +++ b/projects/packages/forms/tests/php/contact-form/Conditional_Logic_Feature_Flag_Test.php @@ -0,0 +1,201 @@ + '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', + 'controls' => array( + 'fieldValue' => array( + '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..43002504c0cb --- /dev/null +++ b/projects/packages/forms/tests/php/contact-form/Conditional_Logic_Initial_Render_Test.php @@ -0,0 +1,268 @@ + true, + 'action' => 'show', + 'logicalOperator' => 'all', + 'controls' => array( + 'fieldValue' => array( + '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..9c80f70049b0 --- /dev/null +++ b/projects/packages/forms/tests/php/contact-form/Conditional_Logic_Parity_Test.php @@ -0,0 +1,118 @@ +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..fe05e3b8d340 --- /dev/null +++ b/projects/packages/forms/tests/php/contact-form/Conditional_Logic_Required_Field_Test.php @@ -0,0 +1,168 @@ +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', + 'controls' => array( + 'fieldValue' => array( + '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..d8514131762e --- /dev/null +++ b/projects/packages/forms/tests/php/contact-form/Conditional_Logic_Test.php @@ -0,0 +1,674 @@ + true, + 'action' => 'show', + 'logicalOperator' => 'all', + 'controls' => array( 'fieldValue' => array( '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', 'number' ), + 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', + 'controls' => array( + 'fieldValue' => array( + '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." + ); + } + } +} 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..675abb392738 --- /dev/null +++ b/projects/packages/forms/tests/php/contact-form/Conditional_Logic_Validation_Test.php @@ -0,0 +1,193 @@ + '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', + 'controls' => array( + 'fieldValue' => array( + '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/Feedback_Conditional_Logic_Test.php b/projects/packages/forms/tests/php/contact-form/Feedback_Conditional_Logic_Test.php new file mode 100644 index 000000000000..2fc7c84b9845 --- /dev/null +++ b/projects/packages/forms/tests/php/contact-form/Feedback_Conditional_Logic_Test.php @@ -0,0 +1,364 @@ + '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', + 'controls' => array( + 'fieldValue' => array( + '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', + 'controls' => array( + 'fieldValue' => array( + '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', + 'controls' => array( + 'fieldValue' => array( + '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', + 'controls' => array( + 'fieldValue' => array( + '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', + 'controls' => array( + 'fieldValue' => array( + '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', + 'controls' => array( + 'fieldValue' => array( + '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/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", From 97dde9704c56fab758f154d51ff3f59c725416d4 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Wed, 12 Aug 2026 12:22:16 -0700 Subject: [PATCH 02/27] Forms: store conditional logic as groups of rules The attribute kept its rules in a map keyed by condition kind: controls: { fieldValue: { rules: [ ... ] } } A map cannot express "any of these AND all of those", so supporting more than one grouping later meant reshaping what is already stored. It is an array now: logicalOperator: 'any', // combines the groups groups: [ { logicalOperator: 'all', rules: [ ... ] }, ] Each group reduces its own rules with its own operator, and the groups reduce with the top-level one. Both evaluators handle several groups already, even though the V1 panel writes exactly one -- if only the storage changed, the second group would still arrive needing an evaluator change, and the migration would only have been deferred. With one group the outer reduction is a no-op, so behaviour is unchanged. Rules now carry their own type, so further condition kinds -- query string, user role, date and time -- become new rule types inside a group rather than another reshape. That was what the `controls` map was reserving space for, and per-rule is the version of it that composes with grouping. 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. The panel is unchanged to look at: it edits one group, and its Any/All selector is now that group's operator rather than the top-level one. Removing the last condition drops the empty group rather than leaving a hollow one behind. The control registry is gone -- its only job was keying the map that no longer exists, and the panel renders the group's rules directly. No migration path: the feature has never shipped enabled, so nothing stored anywhere uses the old shape. Test forms built on a flagged site during review will need their conditions re-added. --- .../conditional-logic/components/panel.jsx | 70 +++--- .../shared/conditional-logic/constants.js | 88 +++++++- .../controls/field-value/edit.jsx | 36 +-- .../conditional-logic/controls/index.js | 33 --- .../shared/conditional-logic/util/evaluate.ts | 85 ++++--- .../forms/src/blocks/shared/settings/index.js | 9 +- .../contact-form/class-conditional-logic.php | 81 +++++-- .../shared/conditional-logic/evaluate.test.js | 139 ++++++++++-- .../shared/conditional-logic/panel.test.jsx | 49 ++-- .../form/conditional-visibility.test.js | 11 +- .../Conditional_Logic_Behaviour_Test.php | 7 +- .../Conditional_Logic_Feature_Flag_Test.php | 7 +- .../Conditional_Logic_Initial_Render_Test.php | 7 +- .../Conditional_Logic_Required_Field_Test.php | 7 +- .../contact-form/Conditional_Logic_Test.php | 211 +++++++++++++++++- .../Conditional_Logic_Validation_Test.php | 7 +- .../Feedback_Conditional_Logic_Test.php | 42 ++-- 17 files changed, 664 insertions(+), 225 deletions(-) delete mode 100644 projects/packages/forms/src/blocks/shared/conditional-logic/controls/index.js 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 index 69a8fb2792b7..7e36e13bc5da 100644 --- a/projects/packages/forms/src/blocks/shared/conditional-logic/components/panel.jsx +++ b/projects/packages/forms/src/blocks/shared/conditional-logic/components/panel.jsx @@ -3,8 +3,13 @@ import { PanelBody, SelectControl } from '@wordpress/components'; import { useCallback, useMemo } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { Stack, Text } from '@wordpress/ui'; -import { normalizeLogic } from '../constants.js'; -import { CONTROLS } from '../controls/index.js'; +import { + countRules, + getPrimaryGroup, + normalizeLogic, + withPrimaryGroupRules, +} from '../constants.js'; +import FieldValueControl from '../controls/field-value/edit.jsx'; import useSubjectFields from '../hooks/use-subject-fields.js'; import '../editor.scss'; @@ -18,18 +23,6 @@ const MATCH_OPTIONS = [ { value: 'all', label: __( 'if all', 'jetpack-forms' ) }, ]; -/** - * Count the rules stored across every control. - * - * @param {object} controls - The `controls` map from the attribute. - * @return {number} Total rule count. - */ -const countRules = controls => - Object.values( controls || {} ).reduce( - ( total, control ) => total + ( Array.isArray( control?.rules ) ? control.rules.length : 0 ), - 0 - ); - /** * The "Conditional logic" inspector panel, injected into every field block. * @@ -57,29 +50,22 @@ const ConditionalLogicPanel = ( { clientId, attributes, setAttributes } ) => { [ logic, updateLogic ] ); + // The panel edits one group, so its Any/All selector is that group's operator. The + // top-level operator combines groups with each other and only starts to matter once a + // second group is editable. + const group = getPrimaryGroup( logic ); + const handleMatchChange = useCallback( - logicalOperator => updateLogic( { ...logic, logicalOperator } ), - [ logic, updateLogic ] + logicalOperator => updateLogic( withPrimaryGroupRules( logic, group.rules, logicalOperator ) ), + [ group.rules, logic, updateLogic ] ); - /** - * Store a control's config, keeping `enabled` in step with whether any rule exists. - * - * Deriving it here rather than exposing a toggle means a field only carries conditional - * logic once it actually has a condition, so untouched fields add nothing to the page. - * - * @param {string} slug - The control slug. - * @param {object} next - The control's next config. - */ - const handleControlChange = useCallback( - ( slug, next ) => { - const controls = { ...logic.controls, [ slug ]: next }; - updateLogic( { ...logic, controls, enabled: countRules( controls ) > 0 } ); - }, - [ logic, updateLogic ] + const handleRulesChange = useCallback( + rules => updateLogic( withPrimaryGroupRules( logic, rules, group.logicalOperator ) ), + [ group.logicalOperator, logic, updateLogic ] ); - const hasConditions = countRules( logic.controls ) > 0; + const hasConditions = countRules( logic ) > 0; return ( @@ -108,7 +94,7 @@ const ConditionalLogicPanel = ( { clientId, attributes, setAttributes } ) => { { ) } - { CONTROLS.map( control => { - const { Edit, slug } = control; - return ( - handleControlChange( slug, next ) } - /> - ); - } ) } + ); diff --git a/projects/packages/forms/src/blocks/shared/conditional-logic/constants.js b/projects/packages/forms/src/blocks/shared/conditional-logic/constants.js index cb8f71046e4e..905ca0595e0d 100644 --- a/projects/packages/forms/src/blocks/shared/conditional-logic/constants.js +++ b/projects/packages/forms/src/blocks/shared/conditional-logic/constants.js @@ -8,18 +8,94 @@ export const DEFAULT_LOGIC = { enabled: false, action: 'show', + // How the groups combine with each other. Inert while there is one group. logicalOperator: 'any', - controls: {}, + 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 => ( { - ...DEFAULT_LOGIC, - ...( stored || {} ), - controls: { ...( stored?.controls || {} ) }, -} ); +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 ); 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 index 5ddb36306267..b483572eaf86 100644 --- 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 @@ -3,6 +3,7 @@ import { useCallback, useMemo } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; import { closeSmall, plus } from '@wordpress/icons'; import { Button, IconButton, Stack, Text } from '@wordpress/ui'; +import { RULE_TYPE_FIELD_VALUE } from '../../constants.js'; import { useEnsureFieldId } from '../../hooks/use-subject-fields.js'; import { OPERATORS, @@ -296,41 +297,44 @@ const RuleRow = ( { rule, index, fields, ownFieldId, onChange, onRemove } ) => { * The Field Value control: a list of conditions comparing sibling fields. * * @param {object} props - Component props. - * @param {object} props.value - This control's stored config, `{ rules }`. - * @param {Function} props.onChange - Called with the control's next config. + * @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 FieldValueControl = ( { value, onChange, fields, ownFieldId } ) => { - const rules = useMemo( () => ( Array.isArray( value?.rules ) ? value.rules : [] ), [ value ] ); +const FieldValueControl = ( { rules: storedRules, onChange, fields, ownFieldId } ) => { + const rules = useMemo( + () => ( Array.isArray( storedRules ) ? storedRules : [] ), + [ storedRules ] + ); const updateRule = useCallback( ( index, patch ) => { - onChange( { - ...value, - rules: rules.map( ( rule, i ) => ( i === index ? { ...rule, ...patch } : rule ) ), - } ); + onChange( rules.map( ( rule, i ) => ( i === index ? { ...rule, ...patch } : rule ) ) ); }, - [ onChange, rules, value ] + [ onChange, rules ] ); const removeRule = useCallback( index => { - onChange( { ...value, rules: rules.filter( ( _, i ) => i !== index ) } ); + onChange( rules.filter( ( _, i ) => i !== index ) ); }, - [ onChange, rules, value ] + [ onChange, rules ] ); // 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( () => { - onChange( { - ...value, - rules: [ ...rules, { field: '', operator: OPERATORS.IS, value: '' } ], - } ); - }, [ onChange, rules, value ] ); + onChange( [ + ...rules, + { type: RULE_TYPE_FIELD_VALUE, field: '', operator: OPERATORS.IS, value: '' }, + ] ); + }, [ onChange, rules ] ); if ( ! fields.length ) { return ( diff --git a/projects/packages/forms/src/blocks/shared/conditional-logic/controls/index.js b/projects/packages/forms/src/blocks/shared/conditional-logic/controls/index.js deleted file mode 100644 index 9045b6acfd1c..000000000000 --- a/projects/packages/forms/src/blocks/shared/conditional-logic/controls/index.js +++ /dev/null @@ -1,33 +0,0 @@ -import { __ } from '@wordpress/i18n'; -import FieldValueControl from './field-value/edit.jsx'; - -/** - * Registry of condition types offered by the "+" menu. - * - * Phase 1 registers only Field Value. Static condition types — query string, user role, date - * and time — are deliberately absent: they are decided once at render, but a submission does - * not carry the request context that produced it, so PHP cannot re-derive them at submit time - * without a signed render-time payload. Adding one here is the only change the panel needs. - * - * Each entry: - * - slug: key under `conditionalLogic.controls` - * - label: shown in the "+" menu and as the section heading - * - defaultValue: config stored when the control is switched on - * - Edit: component rendering the control's body - */ -export const CONTROLS = [ - { - slug: 'fieldValue', - label: __( 'Field value', 'jetpack-forms' ), - defaultValue: { rules: [] }, - Edit: FieldValueControl, - }, -]; - -/** - * Look up a control definition by slug. - * - * @param {string} slug - The control slug. - * @return {object|undefined} The control definition. - */ -export const getControl = slug => CONTROLS.find( control => control.slug === slug ); 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 index 0874939b35d7..632ee0f64ed8 100644 --- a/projects/packages/forms/src/blocks/shared/conditional-logic/util/evaluate.ts +++ b/projects/packages/forms/src/blocks/shared/conditional-logic/util/evaluate.ts @@ -14,17 +14,30 @@ 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'; - controls?: { - fieldValue?: { rules?: Rule[] }; - }; + 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 = { @@ -346,38 +359,60 @@ export const evaluateLogic = ( return true; } - const rules = logic.controls?.fieldValue?.rules; - if ( ! Array.isArray( rules ) || 0 === rules.length ) { + const groups = Array.isArray( logic.groups ) ? logic.groups : []; + if ( 0 === groups.length ) { return true; } - const outcomes: boolean[] = []; - rules.forEach( rule => { - if ( ! rule || ! rule.field || ! rule.operator ) { - return; - } - 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 ); + // 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 === outcomes.length ) { + if ( 0 === groupOutcomes.length ) { return true; } const matched = - 'all' === logic.logicalOperator ? outcomes.every( Boolean ) : outcomes.some( Boolean ); + 'all' === logic.logicalOperator + ? groupOutcomes.every( Boolean ) + : groupOutcomes.some( Boolean ); return 'hide' === logic.action ? ! matched : matched; }; diff --git a/projects/packages/forms/src/blocks/shared/settings/index.js b/projects/packages/forms/src/blocks/shared/settings/index.js index 623f553fdbb6..7e0f7d044117 100644 --- a/projects/packages/forms/src/blocks/shared/settings/index.js +++ b/projects/packages/forms/src/blocks/shared/settings/index.js @@ -25,10 +25,13 @@ export default { default: { enabled: false, action: 'show', + // Combines the groups with each other; each group combines its own rules. logicalOperator: 'any', - // Keyed by control slug so further condition types (query string, user role, - // date and time) become sibling keys rather than a reshape. - controls: {}, + // 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: [], }, }, }, diff --git a/projects/packages/forms/src/contact-form/class-conditional-logic.php b/projects/packages/forms/src/contact-form/class-conditional-logic.php index 114bce0a9475..8123292620a6 100644 --- a/projects/packages/forms/src/contact-form/class-conditional-logic.php +++ b/projects/packages/forms/src/contact-form/class-conditional-logic.php @@ -39,6 +39,15 @@ class Conditional_Logic { const OP_IS_CHECKED = 'is_checked'; const OP_IS_NOT_CHECKED = 'is_not_checked'; + /** + * 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 of the stored attribute. + */ + const RULE_TYPE_FIELD_VALUE = 'fieldValue'; + /** * Shortcode field type to comparison behavior. * @@ -117,47 +126,71 @@ public static function evaluate( $logic, array $field_types, array $form_values, return true; } - $rules = array(); - if ( isset( $logic['controls']['fieldValue']['rules'] ) && is_array( $logic['controls']['fieldValue']['rules'] ) ) { - $rules = $logic['controls']['fieldValue']['rules']; - } + $groups = isset( $logic['groups'] ) && is_array( $logic['groups'] ) ? $logic['groups'] : array(); - if ( empty( $rules ) ) { + if ( empty( $groups ) ) { return true; } - $outcomes = array(); - foreach ( $rules as $rule ) { - if ( ! is_array( $rule ) || empty( $rule['field'] ) || empty( $rule['operator'] ) ) { - continue; - } + // 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(); - $field_id = (string) $rule['field']; - if ( ! array_key_exists( $field_id, $field_types ) ) { - continue; // Subject field no longer exists: ignore this rule. - } + foreach ( $groups as $group ) { + $rules = isset( $group['rules'] ) && is_array( $group['rules'] ) ? $group['rules'] : array(); - $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 ); + $outcomes = array(); + foreach ( $rules as $rule ) { + if ( ! is_array( $rule ) || empty( $rule['field'] ) || empty( $rule['operator'] ) ) { + continue; + } - if ( null !== $outcome ) { - $outcomes[] = $outcome; + // 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( $outcomes ) ) { + if ( empty( $group_outcomes ) ) { return true; } $logical_operator = $logic['logicalOperator'] ?? 'any'; if ( 'all' === $logical_operator ) { - $matched = ! in_array( false, $outcomes, true ); + $matched = ! in_array( false, $group_outcomes, true ); } else { - $matched = in_array( true, $outcomes, true ); + $matched = in_array( true, $group_outcomes, true ); } $action = $logic['action'] ?? 'show'; 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 index 94ad6b3a932b..b8d61af7dfd3 100644 --- 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 @@ -5,13 +5,27 @@ import { resolveVisibility, } from '../../../../../src/blocks/shared/conditional-logic/util/evaluate'; -const logic = ( rules, extra = {} ) => ( { - enabled: true, - action: 'show', - logicalOperator: 'all', - controls: { fieldValue: { rules } }, - ...extra, -} ); +/** + * 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 } ] ); @@ -336,11 +350,12 @@ describe( 'resolveVisibility — cascade', () => { enabled: true, action: 'show', logicalOperator: 'all', - controls: { - fieldValue: { + groups: [ + { + logicalOperator: 'all', rules: [ { field: `f${ i - 1 }`, operator: 'is', value: 'yes' } ], }, - }, + ], }, }; values[ `f${ i }` ] = 'yes'; @@ -379,8 +394,9 @@ describe( 'behaviour parity with PHP', () => { enabled: true, action: 'show', logicalOperator: 'all', - controls: { - fieldValue: { + groups: [ + { + logicalOperator: 'all', rules: [ { field: 'subject', @@ -389,7 +405,7 @@ describe( 'behaviour parity with PHP', () => { }, ], }, - }, + ], }; const visible = evaluateLogic( @@ -403,3 +419,100 @@ describe( 'behaviour parity with PHP', () => { } ); } ); + +/** + * 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/panel.test.jsx b/projects/packages/forms/tests/js/blocks/shared/conditional-logic/panel.test.jsx index 4a9d7f50676c..b85efe76ff12 100644 --- 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 @@ -84,14 +84,14 @@ const DEFAULT_ATTRIBUTE = { enabled: false, action: 'show', logicalOperator: 'any', - controls: {}, + groups: [], }; const withRules = ( rules, extra = {} ) => ( { enabled: true, action: 'show', logicalOperator: 'all', - controls: { fieldValue: { rules } }, + groups: [ { logicalOperator: 'all', rules } ], ...extra, } ); @@ -137,7 +137,12 @@ describe( 'ConditionalLogicPanel', () => { expect( setAttributes ).toHaveBeenCalledWith( { conditionalLogic: expect.objectContaining( { enabled: true, - controls: { fieldValue: { rules: [ { field: '', operator: 'is', value: '' } ] } }, + groups: [ + { + logicalOperator: 'any', + rules: [ { type: 'fieldValue', field: '', operator: 'is', value: '' } ], + }, + ], } ), } ); } ); @@ -152,7 +157,9 @@ describe( 'ConditionalLogicPanel', () => { expect( setAttributes ).toHaveBeenCalledWith( { conditionalLogic: expect.objectContaining( { enabled: false, - controls: { fieldValue: { rules: [] } }, + // The group goes with its last rule, back to the default empty state rather + // than a hollow group nothing reads. + groups: [], } ), } ); } ); @@ -299,9 +306,12 @@ describe( 'ConditionalLogicPanel', () => { expect( setAttributes ).toHaveBeenCalledWith( { conditionalLogic: expect.objectContaining( { - controls: { - fieldValue: { rules: [ { field: '', operator: 'is', value: '' } ] }, - }, + groups: [ + { + logicalOperator: 'all', + rules: [ { type: 'fieldValue', field: '', operator: 'is', value: '' } ], + }, + ], } ), } ); } ); @@ -322,11 +332,12 @@ describe( 'ConditionalLogicPanel', () => { ); expect( setAttributes ).toHaveBeenCalledWith( { conditionalLogic: expect.objectContaining( { - controls: { - fieldValue: { + groups: [ + { + logicalOperator: 'all', rules: [ { field: 'untitled-field', operator: 'is', value: '' } ], }, - }, + ], } ), } ); } ); @@ -340,9 +351,12 @@ describe( 'ConditionalLogicPanel', () => { expect( setAttributes ).toHaveBeenCalledWith( { conditionalLogic: expect.objectContaining( { - controls: { - fieldValue: { rules: [ { field: 'budget_1', operator: 'equals', value: '' } ] }, - }, + groups: [ + { + logicalOperator: 'all', + rules: [ { field: 'budget_1', operator: 'equals', value: '' } ], + }, + ], } ), } ); } ); @@ -359,9 +373,12 @@ describe( 'ConditionalLogicPanel', () => { expect( setAttributes ).toHaveBeenCalledWith( { conditionalLogic: expect.objectContaining( { - controls: { - fieldValue: { rules: [ { field: 'budget_1', operator: 'gte', value: '5' } ] }, - }, + groups: [ + { + logicalOperator: 'all', + rules: [ { field: 'budget_1', operator: 'gte', value: '5' } ], + }, + ], } ), } ); } ); 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 index df89ecd3d1c6..6c16eaf141bd 100644 --- a/projects/packages/forms/tests/js/modules/form/conditional-visibility.test.js +++ b/projects/packages/forms/tests/js/modules/form/conditional-visibility.test.js @@ -8,9 +8,7 @@ const showWhen = ( field, value ) => ( { enabled: true, action: 'show', logicalOperator: 'all', - controls: { - fieldValue: { rules: [ { field, operator: 'is', value } ] }, - }, + groups: [ { logicalOperator: 'all', rules: [ { field, operator: 'is', value } ] } ], } ); /** @@ -153,11 +151,12 @@ describe( 'resolveFormVisibility', () => { enabled: true, action: 'show', logicalOperator: 'all', - controls: { - fieldValue: { + groups: [ + { + logicalOperator: 'all', rules: [ { field: 'colours', operator: 'contains', value: 'Blue' } ], }, - }, + ], }, }, values, 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 index ce21d1de53b4..d711ce354257 100644 --- 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 @@ -48,9 +48,10 @@ public function test_matches_the_shared_behaviour_table( array $case ) { 'enabled' => true, 'action' => 'show', 'logicalOperator' => 'all', - 'controls' => array( - 'fieldValue' => array( - 'rules' => array( + 'groups' => array( + array( + 'logicalOperator' => 'all', + 'rules' => array( array( 'field' => 'subject', 'operator' => $case['operator'], 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 index d31090f71012..5729287fd66c 100644 --- 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 @@ -57,9 +57,10 @@ private function build_form(): Contact_Form { 'enabled' => true, 'action' => 'show', 'logicalOperator' => 'all', - 'controls' => array( - 'fieldValue' => array( - 'rules' => array( + 'groups' => array( + array( + 'logicalOperator' => 'all', + 'rules' => array( array( 'field' => 'trigger', 'operator' => 'is', 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 index 43002504c0cb..673e2b78f3c3 100644 --- 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 @@ -41,9 +41,10 @@ private function logic(): array { 'enabled' => true, 'action' => 'show', 'logicalOperator' => 'all', - 'controls' => array( - 'fieldValue' => array( - 'rules' => array( + 'groups' => array( + array( + 'logicalOperator' => 'all', + 'rules' => array( array( 'field' => 'trigger', 'operator' => 'is', 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 index fe05e3b8d340..53575acfc664 100644 --- 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 @@ -63,9 +63,10 @@ private function parse_form( $trigger_value, $dependent_value ): Contact_Form { 'enabled' => true, 'action' => 'show', 'logicalOperator' => 'all', - 'controls' => array( - 'fieldValue' => array( - 'rules' => array( + 'groups' => array( + array( + 'logicalOperator' => 'all', + 'rules' => array( array( 'field' => 'trigger', 'operator' => 'is', 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 index d8514131762e..85b375b37406 100644 --- a/projects/packages/forms/tests/php/contact-form/Conditional_Logic_Test.php +++ b/projects/packages/forms/tests/php/contact-form/Conditional_Logic_Test.php @@ -30,12 +30,23 @@ class Conditional_Logic_Test extends TestCase { * @return array */ private function logic( array $rules, array $overrides = array() ): array { + // One group holding every rule, which is what the V1 panel writes. A logicalOperator + // override sets that group's operator, since with a single group the top-level one is + // inert -- it combines groups with each other. + $group_operator = $overrides['logicalOperator'] ?? 'all'; + unset( $overrides['logicalOperator'] ); + return array_merge( array( 'enabled' => true, 'action' => 'show', - 'logicalOperator' => 'all', - 'controls' => array( 'fieldValue' => array( 'rules' => $rules ) ), + 'logicalOperator' => 'any', + 'groups' => array( + array( + 'logicalOperator' => $group_operator, + 'rules' => $rules, + ), + ), ), $overrides ); @@ -645,9 +656,10 @@ public function test_a_deep_acyclic_chain_resolves_completely() { 'enabled' => true, 'action' => 'show', 'logicalOperator' => 'all', - 'controls' => array( - 'fieldValue' => array( - 'rules' => array( + 'groups' => array( + array( + 'logicalOperator' => 'all', + 'rules' => array( array( 'field' => 'f' . ( $i - 1 ), 'operator' => 'is', @@ -671,4 +683,193 @@ public function test_a_deep_acyclic_chain_resolves_completely() { ); } } + + /** + * 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 index 675abb392738..4722d5393e97 100644 --- 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 @@ -62,9 +62,10 @@ private function build_form( $trigger_value, $dependent_value ): Contact_Form { 'enabled' => true, 'action' => 'show', 'logicalOperator' => 'all', - 'controls' => array( - 'fieldValue' => array( - 'rules' => array( + 'groups' => array( + array( + 'logicalOperator' => 'all', + 'rules' => array( array( 'field' => 'trigger', 'operator' => 'is', 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 index 2fc7c84b9845..aa29c3829d42 100644 --- 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 @@ -105,9 +105,10 @@ public function test_hidden_dependent_field_is_not_persisted() { 'enabled' => true, 'action' => 'show', 'logicalOperator' => 'any', - 'controls' => array( - 'fieldValue' => array( - 'rules' => array( + 'groups' => array( + array( + 'logicalOperator' => 'all', + 'rules' => array( array( 'field' => 'trigger', 'operator' => 'is', @@ -142,9 +143,10 @@ public function test_visible_dependent_field_is_persisted() { 'enabled' => true, 'action' => 'show', 'logicalOperator' => 'any', - 'controls' => array( - 'fieldValue' => array( - 'rules' => array( + 'groups' => array( + array( + 'logicalOperator' => 'all', + 'rules' => array( array( 'field' => 'trigger', 'operator' => 'is', @@ -174,9 +176,10 @@ public function test_disabled_logic_never_strips_fields() { 'enabled' => false, 'action' => 'show', 'logicalOperator' => 'any', - 'controls' => array( - 'fieldValue' => array( - 'rules' => array( + 'groups' => array( + array( + 'logicalOperator' => 'all', + 'rules' => array( array( 'field' => 'trigger', 'operator' => 'is', @@ -206,9 +209,10 @@ public function test_hide_action_strips_when_rule_matches() { 'enabled' => true, 'action' => 'hide', 'logicalOperator' => 'any', - 'controls' => array( - 'fieldValue' => array( - 'rules' => array( + 'groups' => array( + array( + 'logicalOperator' => 'all', + 'rules' => array( array( 'field' => 'trigger', 'operator' => 'is', @@ -248,9 +252,10 @@ public function test_cascade_strips_the_whole_chain() { 'enabled' => true, 'action' => 'show', 'logicalOperator' => 'all', - 'controls' => array( - 'fieldValue' => array( - 'rules' => array( + 'groups' => array( + array( + 'logicalOperator' => 'all', + 'rules' => array( array( 'field' => $field, 'operator' => $operator, @@ -330,9 +335,10 @@ public function test_storage_agrees_with_validation_about_a_cleared_prefilled_tr 'enabled' => true, 'action' => 'show', 'logicalOperator' => 'all', - 'controls' => array( - 'fieldValue' => array( - 'rules' => array( + 'groups' => array( + array( + 'logicalOperator' => 'all', + 'rules' => array( array( 'field' => 'trigger', 'operator' => 'is', From 49b1756ff041e020953b5d61da8b22539f27e49c Mon Sep 17 00:00:00 2001 From: Brandon Kraft Date: Wed, 12 Aug 2026 17:04:36 -0500 Subject: [PATCH 03/27] Forms: fix conditional-logic CI failures on the resolver The shared behaviour table declared its data provider with only the PHPUnit attribute. The PHP 7.2 and 7.3 matrix runs on a PHPUnit old enough to ignore attributes, so the provider never ran and the test was called with no arguments. Adding the annotation alongside the attribute is what the Jetpack.PHPUnit.Attributes sniff was warning about, and it keeps both PHPUnit generations reading the same table. Reading the fixture now throws when it cannot be parsed rather than falling through. Phan flagged the unguarded array access, and the guard it wants is the one the test needs anyway: a fixture that fails to load would otherwise turn every case into a silent pass, which is the one failure mode a cross-language parity table must not have. Also documents the $format parameter the date fix added to three methods, and fixes two alignment nits and a brace placement. --- .../contact-form/class-conditional-logic.php | 5 ++- .../Conditional_Logic_Behaviour_Test.php | 35 ++++++++++++++----- .../Conditional_Logic_Parity_Test.php | 1 - .../contact-form/Conditional_Logic_Test.php | 2 +- 4 files changed, 31 insertions(+), 12 deletions(-) diff --git a/projects/packages/forms/src/contact-form/class-conditional-logic.php b/projects/packages/forms/src/contact-form/class-conditional-logic.php index 8123292620a6..974757c01824 100644 --- a/projects/packages/forms/src/contact-form/class-conditional-logic.php +++ b/projects/packages/forms/src/contact-form/class-conditional-logic.php @@ -217,7 +217,7 @@ public static function resolve_visibility( array $fields, array $form_values ): $visible[ $field_id ] = true; } - $with_logic = array(); + $with_logic = array(); $field_types = array(); $field_formats = array(); foreach ( $fields as $field_id => $descriptor ) { @@ -288,6 +288,7 @@ public static function resolve_visibility( array $fields, array $form_values ): * @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. */ @@ -436,6 +437,7 @@ private static function to_numeric_pair( $actual, $expected ) { * @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. */ @@ -456,6 +458,7 @@ private static function to_temporal_pair( $actual, $expected, $type_key, $format * * @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. */ 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 index d711ce354257..aaa298a1c1b3 100644 --- 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 @@ -23,16 +23,35 @@ class Conditional_Logic_Behaviour_Test extends BaseTestCase { /** - * The shared case table. + * Read the rows of the shared table. * - * @return array}> + * Throws rather than returning an empty list: a fixture that cannot be read would + * otherwise turn every case below into a silent pass, which is the one failure mode a + * shared parity table must not have. + * + * @throws \RuntimeException When the fixture is missing or does not hold a case list. + * + * @return array> */ - public static function behaviour_cases(): array { + 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 ( $data['cases'] as $case ) { + foreach ( self::load_cases() as $case ) { $cases[ $case['name'] ] = array( $case ); } @@ -40,6 +59,8 @@ public static function behaviour_cases(): array { } /** + * @dataProvider behaviour_cases + * * @param array $case One row of the shared table. */ #[\PHPUnit\Framework\Attributes\DataProvider( 'behaviour_cases' )] @@ -82,11 +103,7 @@ public function test_matches_the_shared_behaviour_table( array $case ) { * The table is only worth anything if both sides actually read it. */ public function test_the_shared_table_covers_every_comparison_family() { - $data = json_decode( - (string) file_get_contents( __DIR__ . '/../../fixtures/conditional-logic-behaviour.json' ), - true - ); - $types = array_unique( array_column( $data['cases'], 'type' ) ); + $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_Parity_Test.php b/projects/packages/forms/tests/php/contact-form/Conditional_Logic_Parity_Test.php index 9c80f70049b0..168e4125092a 100644 --- 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 @@ -114,5 +114,4 @@ public function test_php_field_type_table_matches_the_typescript_source() { 'Field type mapping drift between field-types.ts and Conditional_Logic.' ); } - } 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 index 85b375b37406..02fefaa31998 100644 --- a/projects/packages/forms/tests/php/contact-form/Conditional_Logic_Test.php +++ b/projects/packages/forms/tests/php/contact-form/Conditional_Logic_Test.php @@ -670,7 +670,7 @@ public function test_a_deep_acyclic_chain_resolves_completely() { ), ), ); - $values[ "f$i" ] = 'yes'; + $values[ "f$i" ] = 'yes'; } $visible = Conditional_Logic::resolve_visibility( $descriptors, $values ); From 6db0e028d2c28a5b09d59b857e231e6a2c765cfc Mon Sep 17 00:00:00 2001 From: Brandon Kraft Date: Wed, 12 Aug 2026 17:10:47 -0500 Subject: [PATCH 04/27] Forms: align the conditional-logic descriptor array keys PHPCS treats the double-arrow misalignment as a warning, and this repo's phpcs run exits non-zero on warnings, so it fails the build. --- .../packages/forms/src/contact-form/class-contact-form.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 d224e2a4eba2..0afb0eb817a0 100644 --- a/projects/packages/forms/src/contact-form/class-contact-form.php +++ b/projects/packages/forms/src/contact-form/class-contact-form.php @@ -3991,8 +3991,8 @@ private function compute_field_visibility() { foreach ( $this->fields as $field_id => $field ) { $descriptors[ $field_id ] = array( - 'logic' => $field->get_attribute( 'conditionallogic' ), - 'type' => $field->get_attribute( 'type' ), + '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' ), From c87bfbc6f39774e840f68ca516a00e7d905c2527 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Thu, 13 Aug 2026 11:18:31 -0700 Subject: [PATCH 05/27] Forms: give rating fields their own comparison behaviour A rating was declared as a `number`, and every rule against one was silently dead. The field submits `selected/max` -- `4/5`, not `4` -- so is_numeric() and Number() both refuse it, the pair comes back unparseable, and every numeric operator returns false. "Show when the rating is at least 4" hid its field permanently, and the answer was then dropped at storage. Only is_empty and is_not_empty worked, because they never reach the numeric comparison. The same failure as image-select, and it was hiding in the same place: the type table said the comparison was numeric, and nothing checked what the field actually submits. Rating is its own type key now rather than a special case inside `number`, because two things differ, not one. The submitted value needs unpacking before it can be compared -- only the submitted side, since the rule stores a bare number. And the values worth offering are the field's own scale, so the rule builder lists 1..max from the block's `max` attribute instead of a free number box that would happily accept 6 stars out of 5. The operators are the numeric set, which is why sharing the key looked reasonable. Four cases in the shared behaviour table, so both evaluators are pinned to the same answers; they failed in both languages before this. --- .../forms/src/blocks/field-rating/index.js | 2 +- .../shared/conditional-logic/util/evaluate.ts | 21 ++++++++++-- .../conditional-logic/util/field-options.ts | 13 ++++++++ .../conditional-logic/util/field-types.ts | 23 ++++++++++--- .../contact-form/class-conditional-logic.php | 24 ++++++++++++-- .../fixtures/conditional-logic-behaviour.json | 33 +++++++++++++++++++ .../conditional-logic/block-names.test.js | 2 +- .../conditional-logic/field-options.test.js | 18 ++++++++++ .../conditional-logic/field-types.test.js | 12 +++++++ .../contact-form/Conditional_Logic_Test.php | 2 +- 10 files changed, 138 insertions(+), 12 deletions(-) diff --git a/projects/packages/forms/src/blocks/field-rating/index.js b/projects/packages/forms/src/blocks/field-rating/index.js index b297a8a7985b..dc948aafd762 100644 --- a/projects/packages/forms/src/blocks/field-rating/index.js +++ b/projects/packages/forms/src/blocks/field-rating/index.js @@ -18,7 +18,7 @@ export const form_editor = { * input. A block that omits this simply gets no conditional-logic support. */ export const conditional_logic = { - type: 'number', + type: 'rating', }; export const settings = { 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 index 632ee0f64ed8..9b739068613a 100644 --- a/projects/packages/forms/src/blocks/shared/conditional-logic/util/evaluate.ts +++ b/projects/packages/forms/src/blocks/shared/conditional-logic/util/evaluate.ts @@ -133,6 +133,19 @@ const toSelectionList = ( value: unknown ): string[] => { * @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(); @@ -275,8 +288,12 @@ const evaluateRuleValue = ( } } - if ( 'number' === typeKey ) { - const pair = toNumericPair( actual, expected ); + 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; } 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 index 5d399ef71fcf..bcfdf3c8cecf 100644 --- 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 @@ -86,6 +86,19 @@ export const getFieldOptions = ( block?: MinimalBlock | null ): FieldOption[] => 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 ) : []; 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 index 869c39d463fd..484a0af7b1d5 100644 --- 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 @@ -35,8 +35,9 @@ export type Operator = ( typeof OPERATORS )[ keyof typeof OPERATORS ]; /** * A field's comparison behavior, derived from its block type. Several blocks share a - * key: a slider and a rating both compare numerically, a select and a radio group both - * compare against a fixed option list. + * 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' @@ -47,7 +48,8 @@ export type TypeKey = | 'time' | 'boolean' | 'hidden' - | 'file'; + | 'file' + | 'rating'; export type ValueInputKind = 'text' | 'options' | 'number' | 'date' | 'time' | 'none'; @@ -86,7 +88,7 @@ export const TYPE_KEY_BY_FIELD_TYPE: Record< string, TypeKey > = { 'checkbox-multiple': 'multichoice', number: 'number', slider: 'number', - rating: 'number', + rating: 'rating', date: 'date', time: 'time', checkbox: 'boolean', @@ -111,6 +113,16 @@ const OPERATORS_BY_TYPE_KEY: Record< TypeKey, Operator[] > = { 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, @@ -133,6 +145,9 @@ const VALUE_INPUT_BY_TYPE_KEY: Record< TypeKey, ValueInputKind > = { 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', diff --git a/projects/packages/forms/src/contact-form/class-conditional-logic.php b/projects/packages/forms/src/contact-form/class-conditional-logic.php index 974757c01824..3c720288eaa0 100644 --- a/projects/packages/forms/src/contact-form/class-conditional-logic.php +++ b/projects/packages/forms/src/contact-form/class-conditional-logic.php @@ -71,7 +71,7 @@ class Conditional_Logic { 'checkbox-multiple' => 'multichoice', 'number' => 'number', 'slider' => 'number', - 'rating' => 'number', + 'rating' => 'rating', 'date' => 'date', 'time' => 'time', 'checkbox' => 'boolean', @@ -324,8 +324,12 @@ private static function evaluate_rule_value( array $rule, $type_key, $actual, $f return null; } - if ( 'number' === $type_key ) { - $pair = self::to_numeric_pair( $actual, $expected ); + 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; } @@ -418,6 +422,20 @@ private static function to_selection_list( $value ): array { * * @return array|null Both sides as floats, or null when either side is not numeric. */ + /** + * 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 ); + } + private static function to_numeric_pair( $actual, $expected ) { $left = trim( self::to_comparable_string( $actual ) ); $right = trim( self::to_comparable_string( $expected ) ); diff --git a/projects/packages/forms/tests/fixtures/conditional-logic-behaviour.json b/projects/packages/forms/tests/fixtures/conditional-logic-behaviour.json index e97425c0dbf3..832f70fe7eb3 100644 --- a/projects/packages/forms/tests/fixtures/conditional-logic-behaviour.json +++ b/projects/packages/forms/tests/fixtures/conditional-logic-behaviour.json @@ -219,6 +219,39 @@ "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 } ] } 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 index 0fb9bf661f83..5923662a9775 100644 --- 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 @@ -54,7 +54,7 @@ const EXPECTED_TYPES = { 'jetpack/field-checkbox-multiple': 'multichoice', 'jetpack/field-number': 'number', 'jetpack/field-slider': 'number', - 'jetpack/field-rating': 'number', + 'jetpack/field-rating': 'rating', 'jetpack/field-date': 'date', 'jetpack/field-time': 'time', 'jetpack/field-checkbox': 'boolean', 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 index 364caa585706..d567986a2eb1 100644 --- 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 @@ -124,3 +124,21 @@ describe( 'getFieldOptions', () => { ).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 index f6d444806bc3..3aa6f2cd5288 100644 --- 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 @@ -17,6 +17,7 @@ const CASES = [ [ 'choice', 'options' ], [ 'multichoice', 'options' ], [ 'number', 'number' ], + [ 'rating', 'options' ], [ 'date', 'date' ], [ 'time', 'time' ], [ 'boolean', 'none' ], @@ -38,6 +39,17 @@ const EXPECTED_OPERATORS = { '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' ], 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 index 02fefaa31998..85fe96f0bbed 100644 --- a/projects/packages/forms/tests/php/contact-form/Conditional_Logic_Test.php +++ b/projects/packages/forms/tests/php/contact-form/Conditional_Logic_Test.php @@ -93,7 +93,7 @@ public static function provide_field_types(): array { array( 'checkbox-multiple', 'multichoice' ), array( 'number', 'number' ), array( 'slider', 'number' ), - array( 'rating', 'number' ), + array( 'rating', 'rating' ), array( 'date', 'date' ), array( 'time', 'time' ), array( 'checkbox', 'boolean' ), From db066d6948015f76306deb077103ed64c6b8f452 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Thu, 13 Aug 2026 12:45:39 -0700 Subject: [PATCH 06/27] Forms: edit conditional logic in a dialog instead of the inspector The rule builder lived in a column about 280px wide. Three controls per condition do not fit across it, so each condition stacked into a bordered card, and three or four of those were taller than the viewport -- on top of the action and match selectors and a hint line. The multi-group storage would have made it worse: a second group means two nested lists in that column. The inspector now keeps a summary and a button, and the rules are edited in a wide dialog. That follows jetpack-integration-controls, which already keeps a summary in the inspector and pushes its list into a Modal. With the width available, a condition is one row -- subject, comparison, value, remove -- reading as a sentence rather than a labelled card. Both selectors sit inside the sentence, so the whole rule reads back as "Show this field when all of these match". A long list becomes aligned columns instead of a stack. The summary is why the inspector keeps a panel rather than a bare button: an author can see whether a field is conditional, and roughly why, without opening anything. It states the action, the match mode and the count. Edits still commit straight to the block attribute. There is no draft state and no Save button, so undo remains the editor's own and the rules have one source of truth, matching every other inspector control. Nothing else moves: the stored shape, both evaluators, all the PHP, the isSelected gate, and the lazy boundary are untouched. The dialog renders inside the already-lazy panel chunk, so a site with the feature off still fetches none of it -- verified against a clean build, where editor.js contains no trace of the dialog. Two test changes worth noting. Most of the rule-editing tests needed only the dialog opened first, since they drive the same components. The two that queried through the render container now query the dialog instead, because a Modal portals to the end of the document rather than nesting in what render() returns. --- .../conditional-logic/components/panel.jsx | 165 +++++++++++------- .../components/rules-modal.jsx | 104 +++++++++++ .../controls/field-value/edit.jsx | 55 +++--- .../shared/conditional-logic/editor.scss | 90 +++++----- .../shared/conditional-logic/panel.test.jsx | 107 ++++++++---- 5 files changed, 346 insertions(+), 175 deletions(-) create mode 100644 projects/packages/forms/src/blocks/shared/conditional-logic/components/rules-modal.jsx 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 index 7e36e13bc5da..6410422aa122 100644 --- a/projects/packages/forms/src/blocks/shared/conditional-logic/components/panel.jsx +++ b/projects/packages/forms/src/blocks/shared/conditional-logic/components/panel.jsx @@ -1,31 +1,87 @@ import { InspectorControls } from '@wordpress/block-editor'; -import { PanelBody, SelectControl } from '@wordpress/components'; -import { useCallback, useMemo } from '@wordpress/element'; -import { __ } from '@wordpress/i18n'; -import { Stack, Text } from '@wordpress/ui'; +import { PanelBody } from '@wordpress/components'; +import { useCallback, useMemo, useState } from '@wordpress/element'; +import { __, _n, sprintf } from '@wordpress/i18n'; +import { Button, Stack, Text } from '@wordpress/ui'; import { countRules, getPrimaryGroup, normalizeLogic, withPrimaryGroupRules, } from '../constants.js'; -import FieldValueControl from '../controls/field-value/edit.jsx'; import useSubjectFields from '../hooks/use-subject-fields.js'; +import ConditionalLogicModal from './rules-modal.jsx'; import '../editor.scss'; -const ACTION_OPTIONS = [ - { value: 'show', label: __( 'Show this field', 'jetpack-forms' ) }, - { value: 'hide', label: __( 'Hide this field', 'jetpack-forms' ) }, -]; +/** + * Describe the field's conditions in one line. + * + * This is the whole reason the inspector keeps a panel rather than just a button: an author can + * tell whether a field is conditional, and roughly why, without opening anything. It states the + * action, the match mode and the count, because those answer "what does this field do?" without + * repeating the rules themselves. + * + * @param {object} logic - The normalized conditional-logic attribute. + * @param {object} group - The group being described. + * @return {string} A sentence describing the conditions. + */ +const summarize = ( logic, group ) => { + const count = countRules( logic ); -const MATCH_OPTIONS = [ - { value: 'any', label: __( 'if any', 'jetpack-forms' ) }, - { value: 'all', label: __( 'if all', 'jetpack-forms' ) }, -]; + if ( 'hide' === logic.action ) { + return 'all' === group.logicalOperator + ? sprintf( + /* translators: %d: number of conditions */ + _n( + 'Hidden when %d condition matches', + 'Hidden when all %d conditions match', + count, + 'jetpack-forms' + ), + count + ) + : sprintf( + /* translators: %d: number of conditions */ + _n( + 'Hidden when %d condition matches', + 'Hidden when any of %d conditions match', + count, + 'jetpack-forms' + ), + count + ); + } + + return 'all' === group.logicalOperator + ? sprintf( + /* translators: %d: number of conditions */ + _n( + 'Shown when %d condition matches', + 'Shown when all %d conditions match', + count, + 'jetpack-forms' + ), + count + ) + : sprintf( + /* translators: %d: number of conditions */ + _n( + 'Shown when %d condition matches', + 'Shown when any of %d conditions match', + count, + 'jetpack-forms' + ), + count + ); +}; /** * 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. @@ -33,12 +89,15 @@ const MATCH_OPTIONS = [ * @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 } ), @@ -50,11 +109,6 @@ const ConditionalLogicPanel = ( { clientId, attributes, setAttributes } ) => { [ logic, updateLogic ] ); - // The panel edits one group, so its Any/All selector is that group's operator. The - // top-level operator combines groups with each other and only starts to matter once a - // second group is editable. - const group = getPrimaryGroup( logic ); - const handleMatchChange = useCallback( logicalOperator => updateLogic( withPrimaryGroupRules( logic, group.rules, logicalOperator ) ), [ group.rules, logic, updateLogic ] @@ -65,6 +119,9 @@ const ConditionalLogicPanel = ( { clientId, attributes, setAttributes } ) => { [ group.logicalOperator, logic, updateLogic ] ); + const openModal = useCallback( () => setIsModalOpen( true ), [] ); + const closeModal = useCallback( () => setIsModalOpen( false ), [] ); + const hasConditions = countRules( logic ) > 0; return ( @@ -74,53 +131,35 @@ const ConditionalLogicPanel = ( { clientId, attributes, setAttributes } ) => { initialOpen={ false } className="jetpack-contact-form__panel jetpack-contact-form__conditional-logic" > - { hasConditions ? ( - <> - - - - - - { __( 'of the following conditions are met:', 'jetpack-forms' ) } - - - ) : ( - - { __( - 'Show or hide this field based on the answer to another field.', - 'jetpack-forms' - ) } + + + { hasConditions + ? summarize( logic, group ) + : __( + 'Show or hide this field based on the answer to another field.', + 'jetpack-forms' + ) } - ) } - - + + + + + ); }; 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..5c179ecb16cb --- /dev/null +++ b/projects/packages/forms/src/blocks/shared/conditional-logic/components/rules-modal.jsx @@ -0,0 +1,104 @@ +import { Modal, SelectControl } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { Stack } from '@wordpress/ui'; +import FieldValueControl from '../controls/field-value/edit.jsx'; + +const ACTION_OPTIONS = [ + { value: 'show', label: __( 'Show', 'jetpack-forms' ) }, + { value: 'hide', label: __( 'Hide', 'jetpack-forms' ) }, +]; + +const MATCH_OPTIONS = [ + { value: 'any', label: __( 'any', 'jetpack-forms' ) }, + { value: 'all', label: __( '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 ( + + + { /* Both selectors sit inside the sentence rather than above it as labelled + fields: the action and the match mode are what the sentence says, and + reading it back is how an author checks the rule is what they meant. */ } + + + { __( 'this field when', 'jetpack-forms' ) } + + { __( 'of these match:', 'jetpack-forms' ) } + + + + + + ); +}; + +export default ConditionalLogicModal; 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 index b483572eaf86..cf6cf2dd85c3 100644 --- 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 @@ -2,7 +2,7 @@ import { Notice, SelectControl, TextControl } from '@wordpress/components'; import { useCallback, useMemo } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; import { closeSmall, plus } from '@wordpress/icons'; -import { Button, IconButton, Stack, Text } from '@wordpress/ui'; +import { Button, IconButton, Stack } from '@wordpress/ui'; import { RULE_TYPE_FIELD_VALUE } from '../../constants.js'; import { useEnsureFieldId } from '../../hooks/use-subject-fields.js'; import { @@ -211,35 +211,7 @@ const RuleRow = ( { rule, index, fields, ownFieldId, onChange, onRemove } ) => { }, {} ); return ( -
- - - { sprintf( - /* translators: %d: condition number, starting at 1 */ - __( 'Condition %d', 'jetpack-forms' ), - index + 1 - ) } - - - - + { missingSubject && ( { __( @@ -249,10 +221,14 @@ const RuleRow = ( { rule, index, fields, ownFieldId, onChange, onRemove } ) => { ) } + { /* 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. */ } { /> + + -
+ ); }; diff --git a/projects/packages/forms/src/blocks/shared/conditional-logic/editor.scss b/projects/packages/forms/src/blocks/shared/conditional-logic/editor.scss index aaba99dd539b..22d86193c705 100644 --- a/projects/packages/forms/src/blocks/shared/conditional-logic/editor.scss +++ b/projects/packages/forms/src/blocks/shared/conditional-logic/editor.scss @@ -1,77 +1,71 @@ /** - * Conditional logic inspector panel. + * Conditional logic: inspector summary and the rule-builder dialog. * - * Layout and typography come from the design system: - * Stack handles the flex arrangement and spacing, Text - * handles the prose. What is left here is what those - * components do not express — the card chrome and the - * proportional split of the two summary selectors — and - * it uses wpds tokens rather than literal values. + * 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 it uses wpds tokens + * rather than literal values. */ .jetpack-contact-form__conditional-logic { - .jetpack-contact-form__conditional-logic-intro, - .jetpack-contact-form__conditional-logic-hint { + .jetpack-contact-form__conditional-logic-summary-text { display: block; color: var(--wpds-color-foreground-content-neutral-weak); } +} - .jetpack-contact-form__conditional-logic-intro { - margin-block-end: var(--wpds-dimension-gap-lg); - } +.jetpack-contact-form__conditional-logic-modal { + + // Reads as a sentence, so the selectors size to their content instead of stretching. + .jetpack-contact-form__conditional-logic-sentence { + flex-wrap: wrap; + + .components-base-control, + .components-base-control__field { + margin-block-end: 0; + } - .jetpack-contact-form__conditional-logic-hint { - margin-block: var(--wpds-dimension-gap-sm) var(--wpds-dimension-gap-lg); + .components-select-control { + inline-size: auto; + } } - // Show/hide carries the longer label, so it gets the - // extra room instead of splitting the row evenly. - .jetpack-contact-form__conditional-logic-summary { + // 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 operator 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 - // left the two boxes visibly out of line. Dropping it - // is what makes the row sit flush. + // Both selectors pass __nextHasNoMarginBottom, but BaseControl still emits a bottom + // margin here, which pushes the remove button out of line with the controls. .components-base-control, .components-base-control__field { margin-block-end: 0; } - > *:first-child { - flex: 3 1 0; - min-width: 0; + > *:nth-child(1) { + flex: 4 1 0; + min-inline-size: 0; } - > *:last-child { - flex: 2 1 0; - min-width: 0; + > *:nth-child(2) { + flex: 3 1 0; + min-inline-size: 0; } - } - - // One card per condition, so several conditions read - // as a list rather than an undifferentiated stack of - // selects. - .jetpack-contact-form__conditional-logic-rule { - padding: var(--wpds-dimension-padding-md); - border: - var(--wpds-border-width-xs) solid - var(--wpds-color-stroke-surface-neutral-weak); - border-radius: var(--wpds-border-radius-sm); - } - .jetpack-contact-form__conditional-logic-rule-header { - margin-block-end: var(--wpds-dimension-gap-sm); - } + > *:nth-child(3) { + flex: 4 1 0; + min-inline-size: 0; + } - .jetpack-contact-form__conditional-logic-rule-title { - color: var(--wpds-color-foreground-content-neutral-weak); - text-transform: uppercase; - letter-spacing: 0.5px; + // The remove button keeps its own column rather than flexing, so it stays put as the + // controls above it change width. + > *:last-child { + flex: 0 0 auto; + margin-block-start: var(--wpds-dimension-gap-xs); + } } .jetpack-contact-form__conditional-logic-add { - justify-content: center; - width: 100%; + align-self: flex-start; } } 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 index b85efe76ff12..796639417e68 100644 --- 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 @@ -95,7 +95,7 @@ const withRules = ( rules, extra = {} ) => ( { ...extra, } ); -const setup = async ( conditionalLogic = DEFAULT_ATTRIBUTE ) => { +const setup = async ( conditionalLogic = DEFAULT_ATTRIBUTE, { openModal = true } = {} ) => { const setAttributes = jest.fn(); const { container } = render( { // 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 }; }; @@ -119,10 +125,50 @@ const optionValues = select => describe( 'ConditionalLogicPanel', () => { it( 'renders the panel title', async () => { - await setup(); + 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 } + ); + + expect( screen.getByText( 'Shown when all 2 conditions match' ) ).toBeInTheDocument(); + expect( screen.getByRole( 'button', { name: 'Edit conditions' } ) ).toBeInTheDocument(); + } ); + + 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( 'Hidden when 1 condition matches' ) ).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(); + } ); + it( 'shows the Add condition button with no conditions configured', async () => { await setup(); expect( screen.getByRole( 'button', { name: /add condition/i } ) ).toBeInTheDocument(); @@ -164,39 +210,37 @@ describe( 'ConditionalLogicPanel', () => { } ); } ); - it( 'hides the action and match selectors until a condition exists', async () => { + // 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.queryByLabelText( 'Action' ) ).not.toBeInTheDocument(); - } ); - - it( 'shows the action and match selectors once a condition exists', async () => { - await setup( withRules( [ { field: 'name_1', operator: 'is', value: 'x' } ] ) ); expect( screen.getByLabelText( 'Action' ) ).toBeInTheDocument(); - expect( screen.getByLabelText( 'When' ) ).toBeInTheDocument(); + expect( screen.getByLabelText( 'Match' ) ).toBeInTheDocument(); } ); - // The single-row arrangement itself is CSS; what this can verify is that both selectors - // render together and that the sentence they belong to follows them rather than being - // interleaved, which is what wrapped badly before. - it( 'renders both selectors above the conditions sentence', async () => { - const { container } = await setup( - withRules( [ { field: 'name_1', operator: 'is', value: 'x' } ] ) - ); + // 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' } ] ) ); - expect( screen.getByLabelText( 'Action' ) ).toBeInTheDocument(); - expect( screen.getByLabelText( 'When' ) ).toBeInTheDocument(); - expect( - within( container ).getByText( 'of the following conditions are met:' ) - ).toBeInTheDocument(); + expect( screen.getByText( 'this field when' ) ).toBeInTheDocument(); + expect( screen.getByText( 'of these match:' ) ).toBeInTheDocument(); } ); - it( 'phrases the match options to read on from the action', async () => { + it( 'phrases the selectors to read on from each other', async () => { await setup( withRules( [ { field: 'name_1', operator: 'is', value: 'x' } ] ) ); - const match = screen.getByLabelText( 'When' ); + const action = screen.getByLabelText( 'Action' ); + expect( optionValues( action ) ).toEqual( [ 'show', 'hide' ] ); + expect( within( action ).getByRole( 'option', { name: 'Show' } ) ).toBeInTheDocument(); + + const match = screen.getByLabelText( 'Match' ); expect( optionValues( match ) ).toEqual( [ 'any', 'all' ] ); - expect( within( match ).getByRole( 'option', { name: 'if any' } ) ).toBeInTheDocument(); - expect( within( match ).getByRole( 'option', { name: 'if all' } ) ).toBeInTheDocument(); + expect( within( match ).getByRole( 'option', { name: 'any' } ) ).toBeInTheDocument(); + expect( within( match ).getByRole( 'option', { name: 'all' } ) ).toBeInTheDocument(); } ); it( 'offers the operators belonging to the subject field type', async () => { @@ -292,12 +336,13 @@ describe( 'ConditionalLogicPanel', () => { } ); it( 'warns when a rule references a field that no longer exists', async () => { - // Scoped to the rendered container: Notice mirrors its text into an aria-live region - // that WordPress appends to document.body, which would match twice. - const { container } = await setup( - withRules( [ { field: 'deleted_1', operator: 'is', value: 'x' } ] ) - ); - expect( within( container ).getByText( /no longer exists/i ) ).toBeInTheDocument(); + 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(); } ); it( 'adds a condition with no subject chosen yet', async () => { From 70c01858cfd51b73db17129ab120e165ec5338be Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Thu, 13 Aug 2026 13:17:06 -0700 Subject: [PATCH 07/27] Forms: restore the two-selector heading and give conditions a surface Three changes to the conditional-logic dialog. The heading goes back to the arrangement it had in the inspector: the action and match selectors side by side, with the clause that finishes the sentence on its own line beneath. Folding both into a single inline sentence read tidily in a mockup but left the two controls competing with the prose between them; side by side they are the sentence, and the line underneath finishes it. Each condition now sits on its own tinted surface. In the inspector a border did that work, but a bordered card in a wide dialog reads as a box around nothing -- the tint separates the rows without drawing another outline. The remove control uses the trash icon rather than a cross. The dialog's own close button is a cross, and two crosses on screen doing different things is one too many. --- .../components/rules-modal.jsx | 28 +++++++------- .../controls/field-value/edit.jsx | 4 +- .../shared/conditional-logic/editor.scss | 38 ++++++++++++++----- .../shared/conditional-logic/panel.test.jsx | 17 +++++---- 4 files changed, 56 insertions(+), 31 deletions(-) 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 index 5c179ecb16cb..88bf1919baf0 100644 --- 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 @@ -1,16 +1,16 @@ import { Modal, SelectControl } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; -import { Stack } from '@wordpress/ui'; +import { Stack, Text } from '@wordpress/ui'; import FieldValueControl from '../controls/field-value/edit.jsx'; const ACTION_OPTIONS = [ - { value: 'show', label: __( 'Show', 'jetpack-forms' ) }, - { value: 'hide', label: __( 'Hide', 'jetpack-forms' ) }, + { value: 'show', label: __( 'Show this field', 'jetpack-forms' ) }, + { value: 'hide', label: __( 'Hide this field', 'jetpack-forms' ) }, ]; const MATCH_OPTIONS = [ - { value: 'any', label: __( 'any', 'jetpack-forms' ) }, - { value: 'all', label: __( 'all', 'jetpack-forms' ) }, + { value: 'any', label: __( 'if any', 'jetpack-forms' ) }, + { value: 'all', label: __( 'if all', 'jetpack-forms' ) }, ]; /** @@ -58,13 +58,13 @@ const ConditionalLogicModal = ( { size="large" className="jetpack-contact-form__conditional-logic-modal" > - - { /* Both selectors sit inside the sentence rather than above it as labelled - fields: the action and the match mode are what the sentence says, and - reading it back is how an author checks the rule is what they meant. */ } + + { /* 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. */ } @@ -77,9 +77,8 @@ const ConditionalLogicModal = ( { __nextHasNoMarginBottom={ true } __next40pxDefaultSize={ true } /> - { __( 'this field when', 'jetpack-forms' ) } - { __( 'of these match:', 'jetpack-forms' ) } + + { __( 'of the following conditions are met:', 'jetpack-forms' ) } + + { size="small" variant="minimal" tone="neutral" - icon={ closeSmall } + icon={ trash } onClick={ handleRemove } label={ sprintf( /* translators: %d: condition number, starting at 1 */ diff --git a/projects/packages/forms/src/blocks/shared/conditional-logic/editor.scss b/projects/packages/forms/src/blocks/shared/conditional-logic/editor.scss index 22d86193c705..2c084ec1a907 100644 --- a/projects/packages/forms/src/blocks/shared/conditional-logic/editor.scss +++ b/projects/packages/forms/src/blocks/shared/conditional-logic/editor.scss @@ -2,8 +2,8 @@ * 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 it uses wpds tokens - * rather than literal values. + * components do not express — the proportions of a condition row and the surface it sits on — + * and it uses wpds tokens rather than literal values. */ .jetpack-contact-form__conditional-logic { @@ -16,23 +16,43 @@ .jetpack-contact-form__conditional-logic-modal { - // Reads as a sentence, so the selectors size to their content instead of stretching. + // The two selectors carry the sentence between them. Show/hide holds the longer label, so + // it takes the extra room rather than splitting the row evenly. .jetpack-contact-form__conditional-logic-sentence { - flex-wrap: wrap; .components-base-control, .components-base-control__field { margin-block-end: 0; } - .components-select-control { - inline-size: auto; + > *:first-child { + flex: 3 1 0; + min-inline-size: 0; + } + + > *:last-child { + flex: 2 1 0; + min-inline-size: 0; } } + .jetpack-contact-form__conditional-logic-hint { + display: block; + color: var(--wpds-color-foreground-content-neutral-weak); + } + + // Each condition sits on its own 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 operator is short, and the - // remove button holds a fixed column so the rows line up down the list. + // 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 @@ -58,7 +78,7 @@ } // The remove button keeps its own column rather than flexing, so it stays put as the - // controls above it change width. + // controls beside it change width. > *:last-child { flex: 0 0 auto; margin-block-start: var(--wpds-dimension-gap-xs); 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 index 796639417e68..8fada28a7bdc 100644 --- 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 @@ -216,7 +216,7 @@ describe( 'ConditionalLogicPanel', () => { it( 'offers the action and match selectors in the dialog', async () => { await setup(); expect( screen.getByLabelText( 'Action' ) ).toBeInTheDocument(); - expect( screen.getByLabelText( 'Match' ) ).toBeInTheDocument(); + expect( screen.getByLabelText( 'When' ) ).toBeInTheDocument(); } ); // The row arrangement itself is CSS; what this can verify is that the selectors are the @@ -226,8 +226,9 @@ describe( 'ConditionalLogicPanel', () => { // the document, so it is not a descendant of what render() returns. await setup( withRules( [ { field: 'name_1', operator: 'is', value: 'x' } ] ) ); - expect( screen.getByText( 'this field when' ) ).toBeInTheDocument(); - expect( screen.getByText( 'of these match:' ) ).toBeInTheDocument(); + // The two selectors carry the sentence between them; the clause finishing it sits + // underneath rather than being interleaved with the controls. + expect( screen.getByText( 'of the following conditions are met:' ) ).toBeInTheDocument(); } ); it( 'phrases the selectors to read on from each other', async () => { @@ -235,12 +236,14 @@ describe( 'ConditionalLogicPanel', () => { const action = screen.getByLabelText( 'Action' ); expect( optionValues( action ) ).toEqual( [ 'show', 'hide' ] ); - expect( within( action ).getByRole( 'option', { name: 'Show' } ) ).toBeInTheDocument(); + expect( + within( action ).getByRole( 'option', { name: 'Show this field' } ) + ).toBeInTheDocument(); - const match = screen.getByLabelText( 'Match' ); + const match = screen.getByLabelText( 'When' ); expect( optionValues( match ) ).toEqual( [ 'any', 'all' ] ); - expect( within( match ).getByRole( 'option', { name: 'any' } ) ).toBeInTheDocument(); - expect( within( match ).getByRole( 'option', { name: 'all' } ) ).toBeInTheDocument(); + 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 () => { From 4d002464fced57bcdfc178859fd7fe52153f5e2a Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Thu, 13 Aug 2026 13:24:26 -0700 Subject: [PATCH 08/27] Forms: centre the condition rows and let the heading selectors size to content The controls in a condition row all sit on one line, so the row centres them rather than aligning to the top. The remove button also loses the top offset it carried: that offset existed to line it up against top-aligned controls, and with the row centred it was the thing knocking it out of line. The two heading selectors size to their own labels instead of splitting the row. They are words in a sentence, and stretching them across a wide dialog made a short phrase like "if any" span half the width, which stopped the line reading as a sentence at all -- the problem was only invisible in the inspector because there was no width to spread into. Also rewraps the comments in this file: SCSS here is checked at 80 columns rather than the 100 the JavaScript uses, and the rewrite had been running past it. My commits were using --no-verify, so the pre-commit stylelint never said so. The stacked branches are unaffected. --- .../components/rules-modal.jsx | 2 +- .../controls/field-value/edit.jsx | 2 +- .../shared/conditional-logic/editor.scss | 48 ++++++++++--------- 3 files changed, 28 insertions(+), 24 deletions(-) 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 index 88bf1919baf0..6711e2b744d4 100644 --- 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 @@ -64,7 +64,7 @@ const ConditionalLogicModal = ( { lines top to bottom is how an author checks the rule says what they meant. */ } 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 index 70fc52aa31a3..891e2d96e267 100644 --- 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 @@ -226,7 +226,7 @@ const RuleRow = ( { rule, index, fields, ownFieldId, onChange, onRemove } ) => { list is three aligned columns instead of a stack of cards. */ } diff --git a/projects/packages/forms/src/blocks/shared/conditional-logic/editor.scss b/projects/packages/forms/src/blocks/shared/conditional-logic/editor.scss index 2c084ec1a907..013358bec3f9 100644 --- a/projects/packages/forms/src/blocks/shared/conditional-logic/editor.scss +++ b/projects/packages/forms/src/blocks/shared/conditional-logic/editor.scss @@ -1,9 +1,9 @@ /** * 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 — - * and it uses wpds tokens rather than literal values. + * 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 { @@ -16,23 +16,26 @@ .jetpack-contact-form__conditional-logic-modal { - // The two selectors carry the sentence between them. Show/hide holds the longer label, so - // it takes the extra room rather than splitting the row evenly. + // 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; } - > *:first-child { - flex: 3 1 0; - min-inline-size: 0; + > * { + flex: 0 0 auto; } - > *:last-child { - flex: 2 1 0; - min-inline-size: 0; + .components-select-control, + .components-input-control__container, + select { + inline-size: auto; } } @@ -41,22 +44,23 @@ color: var(--wpds-color-foreground-content-neutral-weak); } - // Each condition sits on its own 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. + // 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. + // 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 pushes the remove button out of line with the controls. + // 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; @@ -77,11 +81,11 @@ 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. + // 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; - margin-block-start: var(--wpds-dimension-gap-xs); } } From 9235ebd9f693d50b60efca78ac8b130171915656 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Thu, 13 Aug 2026 14:10:52 -0700 Subject: [PATCH 09/27] Forms: show whether a condition is finished, and start the author off with one Four changes to the conditional-logic dialog. The inspector button matches Manage integrations -- the components Button at secondary, not the design-system one. Two buttons opening two dialogs from the same inspector should not look like different kinds of thing. The builder opens with a condition waiting to be filled in, instead of an empty pane and an Add button. That row is not written to the block until a field is chosen, so opening the dialog does not mark the post as changed. A condition that names no subject, or gives no value where its operator needs one, is skipped by both evaluators. That was invisible: the field simply did not react, with nothing on screen saying why. Finished conditions now carry a tick at the head of the row, an unfinished one says it will be ignored, and Add condition waits until the current one says something -- otherwise an author can stack up rules that quietly do nothing. The judgement lives in one place and mirrors what the evaluators skip, so the editor and the runtime cannot drift on what counts as a condition. Only a started condition complains. An untouched row is empty, not wrong, and the builder now opens with one of those. --- .../conditional-logic/components/panel.jsx | 6 +- .../controls/field-value/edit.jsx | 96 ++++++++++++++----- .../shared/conditional-logic/editor.scss | 28 +++++- .../conditional-logic/util/rule-validity.js | 53 ++++++++++ .../shared/conditional-logic/panel.test.jsx | 64 ++++++++++--- 5 files changed, 204 insertions(+), 43 deletions(-) create mode 100644 projects/packages/forms/src/blocks/shared/conditional-logic/util/rule-validity.js 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 index 6410422aa122..4c6aa463c36e 100644 --- a/projects/packages/forms/src/blocks/shared/conditional-logic/components/panel.jsx +++ b/projects/packages/forms/src/blocks/shared/conditional-logic/components/panel.jsx @@ -1,8 +1,8 @@ import { InspectorControls } from '@wordpress/block-editor'; -import { PanelBody } from '@wordpress/components'; +import { Button, PanelBody } from '@wordpress/components'; import { useCallback, useMemo, useState } from '@wordpress/element'; import { __, _n, sprintf } from '@wordpress/i18n'; -import { Button, Stack, Text } from '@wordpress/ui'; +import { Stack, Text } from '@wordpress/ui'; import { countRules, getPrimaryGroup, @@ -141,7 +141,7 @@ const ConditionalLogicPanel = ( { clientId, attributes, setAttributes } ) => { ) }
- + + + + { ! canAddCondition && ( + + { __( 'Finish the condition above before adding another.', 'jetpack-forms' ) } + + ) } + ); }; diff --git a/projects/packages/forms/src/blocks/shared/conditional-logic/editor.scss b/projects/packages/forms/src/blocks/shared/conditional-logic/editor.scss index 013358bec3f9..593131b4f18b 100644 --- a/projects/packages/forms/src/blocks/shared/conditional-logic/editor.scss +++ b/projects/packages/forms/src/blocks/shared/conditional-logic/editor.scss @@ -66,17 +66,23 @@ margin-block-end: 0; } - > *:nth-child(1) { + // A fixed leading column, so rows stay aligned whether or not the + // tick is showing. + > *:first-child { + flex: 0 0 auto; + } + + > *:nth-child(2) { flex: 4 1 0; min-inline-size: 0; } - > *:nth-child(2) { + > *:nth-child(3) { flex: 3 1 0; min-inline-size: 0; } - > *:nth-child(3) { + > *:nth-child(4) { flex: 4 1 0; min-inline-size: 0; } @@ -89,6 +95,22 @@ } } + // The tick marks a condition the evaluator will act on. The column keeps + // its width when empty so the controls beside it do not shift. + .jetpack-contact-form__conditional-logic-rule-status { + display: flex; + align-items: center; + justify-content: center; + inline-size: var(--wpds-dimension-size-md); + color: var(--wpds-color-foreground-content-success); + } + + .jetpack-contact-form__conditional-logic-rule-unfinished, + .jetpack-contact-form__conditional-logic-add-hint { + display: block; + color: var(--wpds-color-foreground-content-neutral-weak); + } + .jetpack-contact-form__conditional-logic-add { align-self: flex-start; } 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..a72a6b4220ce --- /dev/null +++ b/projects/packages/forms/src/blocks/shared/conditional-logic/util/rule-validity.js @@ -0,0 +1,53 @@ +import { getValueInputForTypeKey, 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; + } + + // Boolean and file subjects render no value input whatever the operator, so a missing + // value is not something the author can act on. + if ( 'none' === getValueInputForTypeKey( subject.typeKey ) ) { + return true; + } + + 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/tests/js/blocks/shared/conditional-logic/panel.test.jsx b/projects/packages/forms/tests/js/blocks/shared/conditional-logic/panel.test.jsx index 8fada28a7bdc..48b08e614e8a 100644 --- 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 @@ -176,9 +176,20 @@ describe( 'ConditionalLogicPanel', () => { // `enabled` is derived from whether any rule exists, so a field only carries conditional // logic once it actually has a condition. - it( 'enables logic when the first condition is added', async () => { + // 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(); - await userEvent.click( screen.getByRole( 'button', { name: /add condition/i } ) ); + + 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( { @@ -186,7 +197,7 @@ describe( 'ConditionalLogicPanel', () => { groups: [ { logicalOperator: 'any', - rules: [ { type: 'fieldValue', field: '', operator: 'is', value: '' } ], + rules: [ expect.objectContaining( { type: 'fieldValue', field: 'budget_1' } ) ], }, ], } ), @@ -348,20 +359,43 @@ describe( 'ConditionalLogicPanel', () => { ).toBeInTheDocument(); } ); - it( 'adds a condition with no subject chosen yet', async () => { + // 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. + it( 'will not add a second condition until the first says something', async () => { const { setAttributes } = await setup( withRules( [] ) ); - await userEvent.click( screen.getByRole( 'button', { name: 'Add condition' } ) ); + const addButton = screen.getByRole( 'button', { name: 'Add condition' } ); - expect( setAttributes ).toHaveBeenCalledWith( { - conditionalLogic: expect.objectContaining( { - groups: [ - { - logicalOperator: 'all', - rules: [ { type: 'fieldValue', field: '', operator: 'is', value: '' } ], - }, - ], - } ), - } ); + // aria-disabled rather than the disabled attribute: the design-system button stays + // focusable so a keyboard user can reach it and read why it will not act. + expect( addButton ).toHaveAttribute( 'aria-disabled', 'true' ); + expect( + screen.getByText( 'Finish the condition above before adding another.' ) + ).toBeInTheDocument(); + + // An aria-disabled button still receives clicks, so the guard has to hold. + await userEvent.click( addButton ); + expect( setAttributes ).not.toHaveBeenCalled(); + } ); + + 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 with a tick', async () => { + await setup( withRules( [ { field: 'name_1', operator: 'is', value: 'x' } ] ) ); + + expect( screen.getByLabelText( 'Condition is complete' ) ).toBeInTheDocument(); + } ); + + it( 'says why a started condition will be ignored', async () => { + await setup( withRules( [ { field: 'name_1', operator: 'is', value: '' } ] ) ); + + expect( + screen.getByText( 'Give this condition a value, or it will be ignored.' ) + ).toBeInTheDocument(); + expect( screen.queryByLabelText( 'Condition is complete' ) ).not.toBeInTheDocument(); } ); // Regression: fields whose id the renderer derives at output time were filtered out of From d0fc1c35eee7168b821686b69502a30231483ea4 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Thu, 13 Aug 2026 14:26:03 -0700 Subject: [PATCH 10/27] Forms: centre the inspector button, align the hint, and follow focus on add The Edit conditions button centres in the panel rather than sitting against the left edge, so it reads as the panel's action rather than a stray control. The unfinished-condition message lines up with the control it is about. It was starting at the container edge while the controls start after the status column, which left it hanging under the tick. It now repeats that column as an empty spacer instead of computing an offset, so the two stay aligned if the column's width ever changes. Adding a condition moves focus to its field selector. A new row appears empty and the first thing to do with it is choose a subject; it also tells a screen-reader user the row is there at all. Only the row the button just made takes focus, so opening the dialog does not pull focus out of the editor. The focus test needed a harness holding real state. The existing one passes a jest.fn() as setAttributes, so an added condition never comes back as props and the second row never renders -- the assertion would have been about the mock rather than the component. --- .../conditional-logic/components/panel.jsx | 7 ++- .../controls/field-value/edit.jsx | 39 +++++++++++----- .../shared/conditional-logic/editor.scss | 4 ++ .../shared/conditional-logic/panel.test.jsx | 44 +++++++++++++++++++ 4 files changed, 83 insertions(+), 11 deletions(-) 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 index 4c6aa463c36e..6eda49f7c7a0 100644 --- a/projects/packages/forms/src/blocks/shared/conditional-logic/components/panel.jsx +++ b/projects/packages/forms/src/blocks/shared/conditional-logic/components/panel.jsx @@ -141,7 +141,12 @@ const ConditionalLogicPanel = ( { clientId, attributes, setAttributes } ) => { ) } - - - - - - + <> + { /* Only once the field actually has conditions: a toolbar button on every field + block would be noise, and there would be no state for it to report. It sits + beside the Required control because both answer "what is special about this + field?" without opening anything. */ } + { hasConditions && ( + + + + + + ) } + + + + + + { hasConditions + ? summarize( logic, group ) + : __( + 'Show or hide this field based on the answer to another field.', + 'jetpack-forms' + ) } + + + + + + + + + ); }; 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 index b46d5b082d96..49cfb44835fc 100644 --- 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 @@ -57,6 +57,7 @@ const SUBJECT_FIELDS = [ await jest.unstable_mockModule( '@wordpress/block-editor', () => ( { InspectorControls: ( { children } ) =>
{ children }
, + BlockControls: ( { children } ) =>
{ children }
, } ) ); const mockEnsureFieldId = jest.fn( ( field, usedIds = [] ) => { @@ -177,6 +178,38 @@ describe( 'ConditionalLogicPanel', () => { expect( screen.getByText( 'Hidden when 1 condition matches' ) ).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: 'Shown when 1 condition matches' } ) + ).toBeInTheDocument(); + } ); + + it( 'leaves the toolbar alone on a field with no conditions', async () => { + await setup( DEFAULT_ATTRIBUTE, { openModal: false } ); + + expect( + screen.queryByRole( 'button', { name: /Shown when|Hidden when/ } ) + ).not.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: 'Shown when 1 condition matches' } ) + ); + + expect( screen.getByRole( 'dialog' ) ).toBeInTheDocument(); + } ); + it( 'invites the author in when there are no conditions yet', async () => { await setup( DEFAULT_ATTRIBUTE, { openModal: false } ); 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 index 72423b1ef5d4..886b66770811 100644 --- 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 @@ -21,6 +21,7 @@ await jest.unstable_mockModule( '../../../../../src/blocks/contact-form/child-bl await jest.unstable_mockModule( '@wordpress/block-editor', () => ( { InspectorControls: ( { children } ) =>
{ children }
, + BlockControls: ( { children } ) =>
{ children }
, } ) ); await jest.unstable_mockModule( From 12f04e05a2eab5634ca27ea7372a04731b303f3f Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Thu, 13 Aug 2026 14:47:50 -0700 Subject: [PATCH 12/27] Forms: invert the conditional-logic toolbar button while conditions exist Matches how Required marks a field: the is-pressed class, which the toolbar renders as an inverted icon. Two toolbar buttons reporting a field's state should look like the same kind of thing. Note this is always on today, because the button only renders once conditions exist -- there is no un-inverted state to see. Keeping the condition explicit rather than hardcoding the class is what makes an always-visible version a one-line change if the button should sit in the toolbar the way Required does, present whether or not it applies. --- .../shared/conditional-logic/components/panel.jsx | 3 +++ .../js/blocks/shared/conditional-logic/panel.test.jsx | 11 +++++++++++ 2 files changed, 14 insertions(+) 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 index b2bee342c247..c12eab5b3911 100644 --- a/projects/packages/forms/src/blocks/shared/conditional-logic/components/panel.jsx +++ b/projects/packages/forms/src/blocks/shared/conditional-logic/components/panel.jsx @@ -138,6 +138,9 @@ const ConditionalLogicPanel = ( { clientId, attributes, setAttributes } ) => { icon={ 'hide' === logic.action ? unseen : seen } title={ summarize( logic, group ) } onClick={ openModal } + // Inverted while the field carries conditions, the same + // treatment Required uses for a field that is required. + className={ hasConditions ? 'is-pressed' : undefined } /> 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 index 49cfb44835fc..361e3cc38efa 100644 --- 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 @@ -190,6 +190,17 @@ describe( 'ConditionalLogicPanel', () => { ).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: 'Shown when 1 condition matches' } ) ).toHaveClass( + 'is-pressed' + ); + } ); + it( 'leaves the toolbar alone on a field with no conditions', async () => { await setup( DEFAULT_ATTRIBUTE, { openModal: false } ); From b220d19ae6d018a590a3c28816ceb642fe29127d Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Thu, 13 Aug 2026 14:53:36 -0700 Subject: [PATCH 13/27] Forms: keep the conditional-logic toolbar button on every field that supports it The button was appearing only once rules existed, which made it useless for finding the feature -- an author had to already know it was there. It now sits in the toolbar for any block that supports conditional logic, the way Required does, and inverts once the field carries conditions. That also makes it a way in: pressing it opens the builder whether or not rules exist, so conditional logic is reachable from the canvas rather than only from the sidebar. Its tooltip is "Add conditional logic" while the field has none. It cannot be "Conditional logic": that is the inspector panel's title, so a query for a button of that name matches both, which is worth avoiding in the accessibility tree as well as in the tests. --- .../conditional-logic/components/panel.jsx | 38 ++++++++++--------- .../shared/conditional-logic/panel.test.jsx | 19 ++++++++-- 2 files changed, 35 insertions(+), 22 deletions(-) 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 index c12eab5b3911..a0e2846ba26e 100644 --- a/projects/packages/forms/src/blocks/shared/conditional-logic/components/panel.jsx +++ b/projects/packages/forms/src/blocks/shared/conditional-logic/components/panel.jsx @@ -127,24 +127,26 @@ const ConditionalLogicPanel = ( { clientId, attributes, setAttributes } ) => { return ( <> - { /* Only once the field actually has conditions: a toolbar button on every field - block would be noise, and there would be no state for it to report. It sits - beside the Required control because both answer "what is special about this - field?" without opening anything. */ } - { hasConditions && ( - - - - - - ) } + { /* 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. */ } + + + + + { ); } ); - it( 'leaves the toolbar alone on a field with no conditions', async () => { + // 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 } ); - expect( - screen.queryByRole( 'button', { name: /Shown when|Hidden when/ } ) - ).not.toBeInTheDocument(); + 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 () => { From da4d1601a97713f156b6596b19190b9ee328c046 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Thu, 13 Aug 2026 15:23:05 -0700 Subject: [PATCH 14/27] Forms: add Clear all conditions, and offer Add only when it can act Four changes to the rule builder. Clear all conditions removes them in one go, sitting opposite Add rather than beside it so the two are not mistaken for each other. It appears only once there is something to clear. Clearing does not immediately offer another empty row. The builder normally opens with one waiting, but doing that straight after a clear looks like the clear failed -- an author who wants a row presses Add. Add condition is now absent rather than disabled while the current condition is unfinished. A disabled button plus a line explaining why is more to read than a button that is simply not there yet, so the explanatory line goes with it. The inspector's Edit conditions button spans the panel instead of centring, so it reads as the panel's action rather than a control floating in the middle. --- .../controls/field-value/edit.jsx | 62 ++++++++++++------- .../shared/conditional-logic/editor.scss | 8 ++- .../shared/conditional-logic/panel.test.jsx | 57 +++++++++++++---- 3 files changed, 89 insertions(+), 38 deletions(-) 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 index a096e31d6994..1e51bf62da4b 100644 --- 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 @@ -340,10 +340,17 @@ const FieldValueControl = ( { rules: storedRules, onChange, fields, ownFieldId } [ 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 ] ); + // Clearing is deliberate, so the builder does not immediately offer another empty row -- + // that would look like the clear had failed. An author who wants one presses Add. + const [ isCleared, setIsCleared ] = useState( false ); + + // An empty builder otherwise 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 || isCleared ? stored : [ BLANK_RULE ] ), + [ isCleared, 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. @@ -376,10 +383,17 @@ const FieldValueControl = ( { rules: storedRules, onChange, fields, ownFieldId } // 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( () => { + setIsCleared( false ); setFocusIndex( stored.length ); onChange( [ ...stored, { ...BLANK_RULE } ] ); }, [ onChange, stored ] ); + const clearRules = useCallback( () => { + setIsCleared( true ); + setFocusIndex( null ); + onChange( [] ); + }, [ onChange ] ); + // A condition that names no subject, or gives no value where one is needed, is skipped by // both evaluators. Rather than let an author stack up rules that quietly do nothing, the // next one waits until this one says something. @@ -409,25 +423,29 @@ const FieldValueControl = ( { rules: storedRules, onChange, fields, ownFieldId } /> ) ) } - { /* The single entry point for adding conditions. When further condition types - land (query string, user role, date and time) this becomes the menu that - offers the choice, so it stays the panel's one primary action. */ } - - + { /* Adding is the panel's one primary action, so it is offered or it is not -- + a disabled button with an explanation is more to read than a button that + simply is not there yet. Clearing sits opposite it, and only once there is + something to clear. */ } + + { canAddCondition ? ( + + ) : ( + + ) } - { ! canAddCondition && ( - - { __( 'Finish the condition above before adding another.', 'jetpack-forms' ) } - + { stored.length > 0 && ( + ) } diff --git a/projects/packages/forms/src/blocks/shared/conditional-logic/editor.scss b/projects/packages/forms/src/blocks/shared/conditional-logic/editor.scss index a421bd1a4792..1997e0d491d4 100644 --- a/projects/packages/forms/src/blocks/shared/conditional-logic/editor.scss +++ b/projects/packages/forms/src/blocks/shared/conditional-logic/editor.scss @@ -13,8 +13,11 @@ color: var(--wpds-color-foreground-content-neutral-weak); } + // 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: center; + align-self: stretch; + justify-content: center; } } @@ -109,8 +112,7 @@ color: var(--wpds-color-foreground-content-success); } - .jetpack-contact-form__conditional-logic-rule-unfinished, - .jetpack-contact-form__conditional-logic-add-hint { + .jetpack-contact-form__conditional-logic-rule-unfinished { display: block; color: var(--wpds-color-foreground-content-neutral-weak); } 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 index 186555edaa3d..d347040c2242 100644 --- 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 @@ -248,9 +248,11 @@ describe( 'ConditionalLogicPanel', () => { expect( screen.queryByRole( 'button', { name: 'Add condition' } ) ).not.toBeInTheDocument(); } ); - it( 'shows the Add condition button with no conditions configured', async () => { + // 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. + it( 'withholds Add condition while the waiting row is unfinished', async () => { await setup(); - expect( screen.getByRole( 'button', { name: /add condition/i } ) ).toBeInTheDocument(); + expect( screen.queryByRole( 'button', { name: /add condition/i } ) ).not.toBeInTheDocument(); } ); // `enabled` is derived from whether any rule exists, so a field only carries conditional @@ -440,20 +442,49 @@ describe( 'ConditionalLogicPanel', () => { // 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. - it( 'will not add a second condition until the first says something', async () => { - const { setAttributes } = await setup( withRules( [] ) ); - const addButton = screen.getByRole( 'button', { name: 'Add condition' } ); + // 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. + it( 'will not offer a second condition until the first says something', async () => { + await setup( withRules( [ { field: 'name_1', operator: 'is', value: '' } ] ) ); + + expect( screen.queryByRole( 'button', { name: 'Add condition' } ) ).not.toBeInTheDocument(); + } ); - // aria-disabled rather than the disabled attribute: the design-system button stays - // focusable so a keyboard user can reach it and read why it will not act. - expect( addButton ).toHaveAttribute( 'aria-disabled', 'true' ); + it( 'clears every condition at once', 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: 'Clear all conditions' } ) ); + + expect( setAttributes ).toHaveBeenCalledWith( { + conditionalLogic: expect.objectContaining( { enabled: false, groups: [] } ), + } ); + } ); + + // Clearing is deliberate. Offering another empty row straight away would look like the + // clear had not worked. + it( 'does not offer a waiting row after clearing', async () => { + await setupStateful( withRules( [ { field: 'name_1', operator: 'is', value: 'x' } ] ) ); + + await userEvent.click( screen.getByRole( 'button', { name: 'Clear all conditions' } ) ); + + expect( screen.queryByLabelText( 'Field' ) ).not.toBeInTheDocument(); expect( - screen.getByText( 'Finish the condition above before adding another.' ) - ).toBeInTheDocument(); + screen.queryByRole( 'button', { name: 'Clear all conditions' } ) + ).not.toBeInTheDocument(); + } ); - // An aria-disabled button still receives clicks, so the guard has to hold. - await userEvent.click( addButton ); - expect( setAttributes ).not.toHaveBeenCalled(); + it( 'brings a row back when Add condition is pressed after clearing', async () => { + await setupStateful( withRules( [ { field: 'name_1', operator: 'is', value: 'x' } ] ) ); + + await userEvent.click( screen.getByRole( 'button', { name: 'Clear all conditions' } ) ); + await userEvent.click( screen.getByRole( 'button', { name: 'Add condition' } ) ); + + expect( screen.getByLabelText( 'Field' ) ).toBeInTheDocument(); } ); // A new condition appears empty, so the first thing to do with it is choose a subject. From c2c6a5cf1539c544effad6f9b9241492ba8f7f82 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Fri, 14 Aug 2026 08:17:52 -0700 Subject: [PATCH 15/27] Forms: badge each condition active or inactive, and always offer Add Adding a condition was blocked while the current one was unfinished, which made it impossible to add a second one mid-thought -- a normal way to work, and the bug reported here. Add condition is always available now. What the gate was protecting against is instead said per row. A complete condition is badged Active; one that is incomplete is badged Inactive and carries the reason: no field chosen, a field that has since been deleted, or a missing value. That is more useful than the gate was, because it names the row at fault rather than blocking the whole builder. The reason is set on the badge as well as in its tooltip. A tooltip renders nothing until hovered, so on its own it leaves the reason unreachable by keyboard and unread by a screen reader. The tick is gone. It told an author nothing they could act on, and now that the badge carries both states there is no need for a second signal. --- .../controls/field-value/edit.jsx | 90 +++++++++---------- .../shared/conditional-logic/editor.scss | 30 ++----- .../shared/conditional-logic/panel.test.jsx | 30 ++++--- 3 files changed, 70 insertions(+), 80 deletions(-) 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 index 1e51bf62da4b..df72007b4408 100644 --- 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 @@ -1,8 +1,8 @@ -import { Icon, Notice, SelectControl, TextControl } from '@wordpress/components'; +import { Notice, SelectControl, TextControl, Tooltip } from '@wordpress/components'; import { useCallback, useEffect, useMemo, useRef, useState } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; -import { check, plus, trash } from '@wordpress/icons'; -import { Button, IconButton, Stack, Text } from '@wordpress/ui'; +import { plus, trash } from '@wordpress/icons'; +import { Badge, Button, IconButton, Stack } from '@wordpress/ui'; import { RULE_TYPE_FIELD_VALUE } from '../../constants.js'; import { useEnsureFieldId } from '../../hooks/use-subject-fields.js'; import { @@ -12,7 +12,7 @@ import { operatorNeedsValue, } from '../../util/field-types.ts'; import { getOperatorLabel } from '../../util/operator-labels.ts'; -import { areRulesComplete, isRuleComplete, isRuleStarted } from '../../util/rule-validity.js'; +import { isRuleComplete, isRuleStarted } from '../../util/rule-validity.js'; /** * HTML input type for each value-input kind that renders a text box. @@ -208,9 +208,16 @@ const RuleRow = ( { rule, index, fields, ownFieldId, shouldFocus, onChange, onRe const operators = getOperatorsForTypeKey( subject?.typeKey || 'string' ); const isComplete = isRuleComplete( rule, subject ); - // Only complain once the author has actually started this row: an untouched one is empty, - // not wrong, and the builder opens with one waiting. - const isUnfinished = isRuleStarted( rule ) && ! isComplete && ! missingSubject; + + // 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. @@ -247,17 +254,6 @@ const RuleRow = ( { rule, index, fields, ownFieldId, shouldFocus, onChange, onRe gap="sm" className="jetpack-contact-form__conditional-logic-rule-row" > - { /* A fixed leading column so the rows stay aligned whether or not the tick is - there. The tick marks a condition the evaluator will actually act on. */ } - - { isComplete && } - - + { /* Says whether this condition will actually do anything. An incomplete rule + is skipped by both evaluators, which is otherwise invisible -- the field + simply does not react and nothing explains why. */ } + { isComplete ? ( + { __( 'Active', 'jetpack-forms' ) } + ) : ( + + { /* The reason is on the badge as well as in the tooltip: a tooltip + renders nothing until it is hovered, so on its own it leaves the + reason unreachable by keyboard and unread by a screen reader. */ } + + { __( 'Inactive', 'jetpack-forms' ) } + + + ) } + - - { isUnfinished && ( - - { __( 'Give this condition a value, or it will be ignored.', 'jetpack-forms' ) } - - ) } ); }; @@ -394,12 +400,6 @@ const FieldValueControl = ( { rules: storedRules, onChange, fields, ownFieldId } onChange( [] ); }, [ onChange ] ); - // A condition that names no subject, or gives no value where one is needed, is skipped by - // both evaluators. Rather than let an author stack up rules that quietly do nothing, the - // next one waits until this one says something. - const findSubject = rule => fields.find( field => field.id && field.id === rule.field ); - const canAddCondition = areRulesComplete( rules, findSubject ); - if ( ! fields.length ) { return ( @@ -423,24 +423,20 @@ const FieldValueControl = ( { rules: storedRules, onChange, fields, ownFieldId } /> ) ) } - { /* Adding is the panel's one primary action, so it is offered or it is not -- - a disabled button with an explanation is more to read than a button that - simply is not there yet. Clearing sits opposite it, and only once there is - something to clear. */ } + { /* 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 badge already says which conditions are inert. Clearing sits opposite + it, and only once there is something to clear. */ } - { canAddCondition ? ( - - ) : ( - - ) } + { stored.length > 0 && ( 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 index b8033324f78d..e7bc39571354 100644 --- 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 @@ -100,7 +100,8 @@ const ConditionalLogicModal = ( { ) : __( 'This field is hidden by default, until the following conditions are met:', - 'jetpack-forms' + 'jetpack-forms', + 0 ) } 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 index aaa298a1c1b3..6385fc677fa7 100644 --- 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 @@ -69,7 +69,7 @@ public function test_matches_the_shared_behaviour_table( array $case ) { 'enabled' => true, 'action' => 'show', 'logicalOperator' => 'all', - 'groups' => array( + 'groups' => array( array( 'logicalOperator' => 'all', 'rules' => array( 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 index 5729287fd66c..1a7763203d46 100644 --- 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 @@ -57,7 +57,7 @@ private function build_form(): Contact_Form { 'enabled' => true, 'action' => 'show', 'logicalOperator' => 'all', - 'groups' => array( + 'groups' => array( array( 'logicalOperator' => 'all', 'rules' => array( 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 index 673e2b78f3c3..91b54806cb89 100644 --- 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 @@ -41,7 +41,7 @@ private function logic(): array { 'enabled' => true, 'action' => 'show', 'logicalOperator' => 'all', - 'groups' => array( + 'groups' => array( array( 'logicalOperator' => 'all', 'rules' => array( 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 index 53575acfc664..964ec308425c 100644 --- 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 @@ -63,7 +63,7 @@ private function parse_form( $trigger_value, $dependent_value ): Contact_Form { 'enabled' => true, 'action' => 'show', 'logicalOperator' => 'all', - 'groups' => array( + 'groups' => array( array( 'logicalOperator' => 'all', 'rules' => array( 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 index 85fe96f0bbed..e3223a1a4fd0 100644 --- a/projects/packages/forms/tests/php/contact-form/Conditional_Logic_Test.php +++ b/projects/packages/forms/tests/php/contact-form/Conditional_Logic_Test.php @@ -656,7 +656,7 @@ public function test_a_deep_acyclic_chain_resolves_completely() { 'enabled' => true, 'action' => 'show', 'logicalOperator' => 'all', - 'groups' => array( + 'groups' => array( array( 'logicalOperator' => 'all', 'rules' => array( 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 index 4722d5393e97..ff8c67d6f9a9 100644 --- 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 @@ -62,7 +62,7 @@ private function build_form( $trigger_value, $dependent_value ): Contact_Form { 'enabled' => true, 'action' => 'show', 'logicalOperator' => 'all', - 'groups' => array( + 'groups' => array( array( 'logicalOperator' => 'all', 'rules' => array( 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 index aa29c3829d42..4403bba0cc42 100644 --- 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 @@ -105,7 +105,7 @@ public function test_hidden_dependent_field_is_not_persisted() { 'enabled' => true, 'action' => 'show', 'logicalOperator' => 'any', - 'groups' => array( + 'groups' => array( array( 'logicalOperator' => 'all', 'rules' => array( @@ -143,7 +143,7 @@ public function test_visible_dependent_field_is_persisted() { 'enabled' => true, 'action' => 'show', 'logicalOperator' => 'any', - 'groups' => array( + 'groups' => array( array( 'logicalOperator' => 'all', 'rules' => array( @@ -176,7 +176,7 @@ public function test_disabled_logic_never_strips_fields() { 'enabled' => false, 'action' => 'show', 'logicalOperator' => 'any', - 'groups' => array( + 'groups' => array( array( 'logicalOperator' => 'all', 'rules' => array( @@ -209,7 +209,7 @@ public function test_hide_action_strips_when_rule_matches() { 'enabled' => true, 'action' => 'hide', 'logicalOperator' => 'any', - 'groups' => array( + 'groups' => array( array( 'logicalOperator' => 'all', 'rules' => array( @@ -252,7 +252,7 @@ public function test_cascade_strips_the_whole_chain() { 'enabled' => true, 'action' => 'show', 'logicalOperator' => 'all', - 'groups' => array( + 'groups' => array( array( 'logicalOperator' => 'all', 'rules' => array( @@ -335,7 +335,7 @@ public function test_storage_agrees_with_validation_about_a_cleared_prefilled_tr 'enabled' => true, 'action' => 'show', 'logicalOperator' => 'all', - 'groups' => array( + 'groups' => array( array( 'logicalOperator' => 'all', 'rules' => array( From c31bbd6da83a0891b1541aaec02d0ede79356b61 Mon Sep 17 00:00:00 2001 From: Brandon Kraft Date: Fri, 14 Aug 2026 17:05:42 -0500 Subject: [PATCH 24/27] Forms: align conditional field visibility --- .../contact-form/class-contact-form-field.php | 136 +++++++++-- .../src/contact-form/class-contact-form.php | 41 +++- .../forms/src/contact-form/class-feedback.php | 19 ++ .../class-hostinger-reach-integration.php | 33 +-- .../service/class-mailpoet-integration.php | 25 +- .../forms/src/service/class-post-to-url.php | 7 +- .../contact-form/Contact_Form_Field_Test.php | 219 ++++++++++++++++++ .../Integration_Filtered_Fields_Test.php | 111 +++++++++ .../tests/php/service/Post_To_Url_Test.php | 37 ++- 9 files changed, 557 insertions(+), 71 deletions(-) create mode 100644 projects/packages/forms/tests/php/service/Integration_Filtered_Fields_Test.php 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 5314f5c56a98..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. * @@ -819,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. @@ -858,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. * @@ -2006,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"; } /** 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 0afb0eb817a0..823ec0ceffcc 100644 --- a/projects/packages/forms/src/contact-form/class-contact-form.php +++ b/projects/packages/forms/src/contact-form/class-contact-form.php @@ -601,6 +601,35 @@ public function __construct( $attributes, $content = null, $set_id = true ) { $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. * @@ -2658,12 +2687,7 @@ 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. // @@ -4003,10 +4027,7 @@ private function compute_field_visibility() { // 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_computed_field_value( - $field->get_attribute( 'type' ), - $field->get_attribute( 'id' ) - ); + $values[ $field_id ] = $field->get_conditional_logic_value(); } return Conditional_Logic::resolve_visibility( $descriptors, $values ); diff --git a/projects/packages/forms/src/contact-form/class-feedback.php b/projects/packages/forms/src/contact-form/class-feedback.php index cbcd325a9692..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. * @@ -810,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. * @@ -1689,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 ); } @@ -1706,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 ); } } 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/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/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 ); } /** From c85467aabc2a8535f4dae8da48aa61892d083ea3 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Fri, 14 Aug 2026 16:32:09 -0700 Subject: [PATCH 25/27] Forms: list a field's conditions in the inspector instead of counting them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The summary said "Shown when 2 conditions match", which tells an author how many rules there are but not what they say -- so the only way to find out was to open the dialog, which is what the summary exists to avoid. It now reads: This field is shown only if: Phone is “iPhone” Email is not empty Each line is the sentence the author built, in the same words the rule builder uses for it, so the two cannot describe the same rule differently. Only the conditions that will actually be acted on are listed. An incomplete rule is skipped by both evaluators, so including it would describe behaviour the field does not have. The heading is four separate strings rather than one assembled from fragments: "shown"/"hidden" and "only if"/"any of these" do not slot into every language the same way, and a sentence built by concatenation cannot be reordered by a translator. The toolbar tooltip gets the same summary on one line, from the same functions, so the canvas and the sidebar cannot drift. The conditions are marked up as a list, so a screen reader announces how many there are before reading them. --- .../conditional-logic/components/panel.jsx | 118 +++++++----------- .../shared/conditional-logic/editor.scss | 13 ++ .../shared/conditional-logic/util/summary.js | 111 ++++++++++++++++ .../shared/conditional-logic/panel.test.jsx | 19 +-- .../shared/conditional-logic/summary.test.js | 78 ++++++++++++ 5 files changed, 256 insertions(+), 83 deletions(-) create mode 100644 projects/packages/forms/src/blocks/shared/conditional-logic/util/summary.js create mode 100644 projects/packages/forms/tests/js/blocks/shared/conditional-logic/summary.test.js 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 index 6b6329298101..e31c52cecc61 100644 --- a/projects/packages/forms/src/blocks/shared/conditional-logic/components/panel.jsx +++ b/projects/packages/forms/src/blocks/shared/conditional-logic/components/panel.jsx @@ -1,7 +1,7 @@ import { BlockControls, InspectorControls } from '@wordpress/block-editor'; import { Button, PanelBody, ToolbarButton, ToolbarGroup } from '@wordpress/components'; import { useCallback, useMemo, useState } from '@wordpress/element'; -import { __, _n, sprintf } from '@wordpress/i18n'; +import { __ } from '@wordpress/i18n'; import { seen, unseen } from '@wordpress/icons'; import { Stack, Text } from '@wordpress/ui'; import clsx from 'clsx'; @@ -13,71 +13,15 @@ import { 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'; -/** - * Describe the field's conditions in one line. - * - * This is the whole reason the inspector keeps a panel rather than just a button: an author can - * tell whether a field is conditional, and roughly why, without opening anything. It states the - * action, the match mode and the count, because those answer "what does this field do?" without - * repeating the rules themselves. - * - * @param {object} logic - The normalized conditional-logic attribute. - * @param {object} group - The group being described. - * @return {string} A sentence describing the conditions. - */ -const summarize = ( logic, group ) => { - const count = countRules( logic ); - - if ( 'hide' === logic.action ) { - return 'all' === group.logicalOperator - ? sprintf( - /* translators: %d: number of conditions */ - _n( - 'Hidden when %d condition matches', - 'Hidden when all %d conditions match', - count, - 'jetpack-forms' - ), - count - ) - : sprintf( - /* translators: %d: number of conditions */ - _n( - 'Hidden when %d condition matches', - 'Hidden when any of %d conditions match', - count, - 'jetpack-forms' - ), - count - ); - } - - return 'all' === group.logicalOperator - ? sprintf( - /* translators: %d: number of conditions */ - _n( - 'Shown when %d condition matches', - 'Shown when all %d conditions match', - count, - 'jetpack-forms' - ), - count - ) - : sprintf( - /* translators: %d: number of conditions */ - _n( - 'Shown when %d condition matches', - 'Shown when any of %d conditions match', - count, - 'jetpack-forms' - ), - count - ); -}; - /** * The "Conditional logic" inspector panel, injected into every field block. * @@ -127,6 +71,10 @@ const ConditionalLogicPanel = ( { clientId, attributes, setAttributes } ) => { 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, @@ -140,8 +88,8 @@ const ConditionalLogicPanel = ( { clientId, attributes, setAttributes } ) => { // author sees on the canvas. icon={ startsHidden( logic ) ? unseen : seen } title={ - hasConditions - ? summarize( logic, group ) + activeConditions.length + ? getSummaryText( logic, group, fields ) : __( 'Add conditional logic', 'jetpack-forms' ) } onClick={ openModal } @@ -161,17 +109,35 @@ const ConditionalLogicPanel = ( { clientId, attributes, setAttributes } ) => { className="jetpack-contact-form__panel jetpack-contact-form__conditional-logic" > - - { hasConditions - ? summarize( logic, group ) - : __( - 'Show or hide this field based on the answer to another field.', - 'jetpack-forms' - ) } - + { 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 ) => ( +
  • + { describeRule( rule, subject ) } +
  • + ) ) } +
+
+ ) : ( + + { __( + 'Show or hide this field based on the answer to another field.', + 'jetpack-forms' + ) } + + ) } - - { stored.length > 0 && ( - - ) } -
+ per-row icon already says which conditions are inert. */ } + ); }; 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 index 9236237140f9..ff7ef62dbe1b 100644 --- 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 @@ -520,43 +520,6 @@ describe( 'ConditionalLogicPanel', () => { expect( screen.queryByLabelText( 'This condition is active.' ) ).not.toBeInTheDocument(); } ); - it( 'clears every condition at once', 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: 'Clear all conditions' } ) ); - - expect( setAttributes ).toHaveBeenCalledWith( { - conditionalLogic: expect.objectContaining( { enabled: false, groups: [] } ), - } ); - } ); - - // Clearing is deliberate. Offering another empty row straight away would look like the - // clear had not worked. - it( 'does not offer a waiting row after clearing', async () => { - await setupStateful( withRules( [ { field: 'name_1', operator: 'is', value: 'x' } ] ) ); - - await userEvent.click( screen.getByRole( 'button', { name: 'Clear all conditions' } ) ); - - expect( screen.queryByLabelText( 'Field' ) ).not.toBeInTheDocument(); - expect( - screen.queryByRole( 'button', { name: 'Clear all conditions' } ) - ).not.toBeInTheDocument(); - } ); - - it( 'brings a row back when Add condition is pressed after clearing', async () => { - await setupStateful( withRules( [ { field: 'name_1', operator: 'is', value: 'x' } ] ) ); - - await userEvent.click( screen.getByRole( 'button', { name: 'Clear all conditions' } ) ); - await userEvent.click( screen.getByRole( 'button', { name: 'Add condition' } ) ); - - expect( screen.getByLabelText( 'Field' ) ).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 () => {