-
Notifications
You must be signed in to change notification settings - Fork 890
Forms: conditional logic (3/5) — apply in the browser #50978
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
36afd07
1ff519e
c84a7fb
31baed0
e107668
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| Significance: patch | ||
| Type: added | ||
|
|
||
| Show and hide form fields in the browser as conditional logic rules are satisfied. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, {signature: string, map: object}>} | ||
| */ | ||
| 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 ]; | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 <body> 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( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This does the opposite of what the comment two lines up says. Three smaller things in the same block:
Walking forward from Checked the mount behavior while I was here and it's fine: |
||
| element => ! ref.contains( element ) && null !== element.offsetParent | ||
| ); | ||
|
|
||
| ( next || form )?.focus?.(); | ||
| }, | ||
|
|
||
| initializeField() { | ||
| const context = getContext(); | ||
| const { fieldId, fieldType, fieldLabel, fieldValue, fieldIsRequired, fieldExtra } = context; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This guard is inverted with respect to the problem the docblock describes, and I think it means the handler can never fire usefully.
persisted === truemeans the browser reused the whole document including the JS heap. The interactivity context is the same object graph it was at freeze time, and every DOM value is the one the last handler already wrote into it — there's nothing to repair. The case the comment describes, where the browser "puts the visitor's values straight back into the DOM" while the context holds stale values, is session-history form restoration on a fresh document: Back without bfcache, or a reload. That firespageshowwithpersisted === falseand returns here on line 44.The skipped case, concretely: visitor answers a select "Are you a member? → Yes", field B appears, they navigate away and press Back without bfcache. The document is re-parsed,
fieldscomes back empty and re-registers from the server-renderedfieldValue, then the browser restores the select to "Yes" in the DOM. Text fields self-correct because they carrydata-wp-bind--value='state.getFieldValue', but select, radio and checkbox have no value binding — so the DOM says "Yes" and the context says "". The client resolves B hidden,new FormData( form )postsmember=Yesanyway, and the server resolves B visible and required and rejects the submission for a field the visitor never saw.Second half, on line 51: even when it does run,
inputreaches almost nothing. Counting the wiring inclass-contact-form-field.phpon this branch — radio (1516, 1555), checkbox (1634), explicit consent (1662), checkbox-multiple (2137, 2168), select (2209), image-select (2581) and rating (3199) are alldata-wp-on--changeexclusively.data-wp-on--inputis only on the free-text controls. So a syntheticinputis a no-op for every choice-style field, which is the set most likely to be a conditional trigger.Dropping the
persistedguard and dispatching both event types covers it:[ 'input', 'change' ].forEach( type => element.dispatchEvent( new Event( type, { bubbles: true } ) ) ). Worth scoping the selector to forms that actually carry conditional logic so flag-off sites pay nothing.Neither half is currently covered by a test, which is probably why both survived — a jsdom case dispatching
pageshowwithpersisted: falseagainst a restored select would catch it.