import { __ } from '@wordpress/i18n'; import { PanelBody, SelectControl, TextControl, ToggleControl, Button, Flex, FlexItem, Notice, } from '@wordpress/components'; import { useSelect } from '@wordpress/data'; import { useEffect, useState, useMemo } from '@wordpress/element'; import { useExistingFieldsSnapshot } from '../lib/existingFieldsSnapshot'; const OPERATORS = [ 'is', 'is not', 'less than', 'more than' ]; // The VALUES above are the stored enum and must never be translated — the // frontend compares them literally in features/conditional-fields/. Only the // dropdown label is localised, via this map. const OPERATOR_LABELS = () => ( { 'is': __( 'is', 'profile-builder' ), 'is not': __( 'is not', 'profile-builder' ), 'less than': __( 'less than', 'profile-builder' ), 'more than': __( 'more than', 'profile-builder' ), } ); const NUMERIC_OPERATORS = [ 'less than', 'more than' ]; const blankRule = () => ( { field: '', operator: 'is', value: '' } ); // Same inline plus as AddFieldButton's, for one visual language across the // editor's "add" affordances. Deliberately not @wordpress/icons — importing it // would pull a `wp-icons` script handle into the bundle's dependency list. const PlusIcon = () => ( ); /** * Reads window.wppbFb.conditionalFields with safe defaults so a missing localization * doesn't crash the editor — it just disables the source-field dropdown. */ function getConfig() { const fb = ( typeof window !== 'undefined' && window.wppbFb ) || {}; const cfg = fb.conditionalFields || {}; return { blockToFieldType: cfg.blockToFieldType || {}, notAllowedAsSource: cfg.notAllowedAsSource || [], // [] and not a hardcoded copy of the server list — same reason as // reservedSubstrings in BaseFieldEdit: an absent bridge degrades to // "no numeric operators offered", never to a stale list. numericFieldTypes: cfg.numericFieldTypes || [], restRoot: cfg.restRoot || '', restNonce: cfg.restNonce || '', }; } /** * Walks the live editor block tree (recursive — repeater children included) and * returns a `Map(id → { title, fieldType, attributes })` of every block that has * been allocated an id. Used both to overlay fresh edits onto the global snapshot * and to compute the in-form flag for each candidate. */ function collectCanvasMap( blocks, config, out = new Map() ) { for ( const block of blocks ) { const fieldType = config.blockToFieldType[ block.name ]; const id = block.attributes?.id; if ( fieldType && id ) { out.set( id, { title: block.attributes[ 'field-title' ] || `(field #${ id })`, fieldType, attributes: block.attributes, } ); } if ( block.innerBlocks && block.innerBlocks.length ) { collectCanvasMap( block.innerBlocks, config, out ); } } return out; } /** * Merge global field snapshot with live canvas into CL rule sources. * Canvas wins on overlap; each entry has `inForm` for the off-form warning. */ function mergeSourceFields( snapshotEntries, canvasMap, config, currentId ) { const seen = new Set(); const out = []; const accept = ( id, fieldType ) => ( id && id !== currentId && fieldType && ! config.notAllowedAsSource.includes( fieldType ) ); canvasMap.forEach( ( info, id ) => { if ( ! accept( id, info.fieldType ) ) return; seen.add( id ); out.push( { id, title: info.title, fieldType: info.fieldType, attributes: info.attributes, inForm: true, } ); } ); for ( const entry of snapshotEntries ) { const id = entry.id; if ( seen.has( id ) ) continue; if ( ! accept( id, entry.fieldType ) ) continue; out.push( { id, title: entry.title || `(field #${ id })`, fieldType: entry.fieldType, attributes: entry.attributes || {}, inForm: false, } ); } return out; } /** * Parses the rules JSON stored on the block, normalising the legacy shape (rules can * be either an object or array on disk) into a plain array. */ function parseLogic( raw ) { if ( ! raw ) { return { action_type: 'show', logic_type: 'all', rules: [] }; } let parsed; try { parsed = JSON.parse( raw ); } catch ( e ) { return { action_type: 'show', logic_type: 'all', rules: [] }; } let rules = parsed.rules; if ( rules && ! Array.isArray( rules ) ) { rules = Object.keys( rules ).map( ( k ) => rules[ k ] ); } return { action_type: parsed.action_type === 'hide' ? 'hide' : 'show', logic_type: parsed.logic_type === 'any' ? 'any' : 'all', rules: Array.isArray( rules ) ? rules : [], }; } /** * Value control for a rule: Select or TextControl from the source field's options. * CPT/Taxonomy/User Role/Country/Currency load options from REST (ISO codes need labels). */ function RuleValueControl( { sourceField, value, onChange } ) { const config = getConfig(); const [ remoteOptions, setRemoteOptions ] = useState( null ); const [ loading, setLoading ] = useState( false ); const enriched = sourceField && [ 'Select (CPT)', 'Select (Taxonomy)', 'Select (User Role)', 'Select (Country)', 'Select (Currency)', ].includes( sourceField.fieldType ); const enrichmentKey = enriched ? sourceField.fieldType + '|' + ( sourceField.attributes?.cpt || sourceField.attributes?.taxonomy || sourceField.attributes?.[ 'user-roles' ] || '' ) : null; useEffect( () => { if ( ! enriched || ! enrichmentKey || ! config.restRoot ) { setRemoteOptions( null ); return undefined; } // Cancellation guard for a fast source-field switch: a new fetch fires // while the previous is in flight, and an older response can resolve // last, overwriting `remoteOptions`/`loading` with stale data for the // wrong source field. Ignore any response whose effect run was // superseded / unmounted. let cancelled = false; setLoading( true ); const params = new URLSearchParams( { field_type: sourceField.fieldType } ); if ( sourceField.attributes?.cpt ) params.set( 'cpt', sourceField.attributes.cpt ); if ( sourceField.attributes?.taxonomy ) params.set( 'taxonomy', sourceField.attributes.taxonomy ); if ( sourceField.attributes?.[ 'user-roles' ] ) params.set( 'user_roles', sourceField.attributes[ 'user-roles' ] ); fetch( config.restRoot + '?' + params.toString(), { credentials: 'same-origin', headers: { 'X-WP-Nonce': config.restNonce }, } ) .then( ( r ) => r.json() ) .then( ( data ) => { if ( ! cancelled ) setRemoteOptions( data && data.values ? data : { values: [], labels: [] } ); } ) .catch( () => { if ( ! cancelled ) setRemoteOptions( { values: [], labels: [] } ); } ) .finally( () => { if ( ! cancelled ) setLoading( false ); } ); return () => { cancelled = true; }; }, [ enrichmentKey ] ); if ( ! sourceField ) { return ( ); } // Static options coming from the block's `options` attribute (Select/Radio/Checkbox). let staticValues = []; let staticLabels = []; const optsRaw = sourceField.attributes?.options; if ( typeof optsRaw === 'string' && optsRaw.trim() !== '' ) { staticValues = optsRaw.split( ',' ).map( ( v ) => v.trim() ); const labelsRaw = sourceField.attributes?.labels; staticLabels = ( typeof labelsRaw === 'string' && labelsRaw.trim() !== '' ) ? labelsRaw.split( ',' ).map( ( v ) => v.trim() ) : staticValues; } const remoteValues = remoteOptions?.values || []; const remoteLabels = remoteOptions?.labels || []; if ( enriched ) { if ( loading ) { return ; } if ( remoteValues.length === 0 ) { return ( ); } const options = [ { label: __( 'Choose…', 'profile-builder' ), value: '' }, ...remoteValues.map( ( v, i ) => ( { value: v, label: remoteLabels[ i ] || v } ) ), ]; return ( ); } if ( staticValues.length > 0 ) { const options = [ { label: __( 'Choose…', 'profile-builder' ), value: '' }, ...staticValues.map( ( v, i ) => ( { value: v, label: staticLabels[ i ] || v } ) ), ]; return ( ); } return ( ); } export default function ConditionalLogicPanel( { attributes, setAttributes } ) { const config = getConfig(); const enabled = attributes[ 'conditional-logic-enabled' ] === 'yes'; const logic = useMemo( () => parseLogic( attributes[ 'conditional-logic' ] ), [ attributes[ 'conditional-logic' ] ] ); // Live canvas: in-form set + title overlay on the global snapshot. const canvasMap = useSelect( ( select ) => { const blocks = select( 'core/block-editor' ).getBlocks(); return collectCanvasMap( blocks, config ); }, [] ); // Global live snapshot (not window.wppbFb — that stays frozen at page load). // Top-level rows only; sub-fields are not enforceable as CL sources. const snapshotEntries = useExistingFieldsSnapshot(); const sourceFields = useMemo( () => mergeSourceFields( snapshotEntries, canvasMap, config, attributes.id ), [ snapshotEntries, canvasMap, attributes.id ] ); const sourcesById = useMemo( () => { const m = {}; sourceFields.forEach( ( f ) => { m[ f.id ] = f; } ); return m; }, [ sourceFields ] ); // Split + sort sources once for every rule's dropdown. const groupedSources = useMemo( () => { const byTitle = ( a, b ) => a.title.localeCompare( b.title ); return { inForm: sourceFields.filter( ( f ) => f.inForm ).sort( byTitle ), offForm: sourceFields.filter( ( f ) => ! f.inForm ).sort( byTitle ), }; }, [ sourceFields ] ); function commit( next ) { setAttributes( { 'conditional-logic': JSON.stringify( next ) } ); } function setActionType( v ) { commit( { ...logic, action_type: v } ); } function setLogicType( v ) { commit( { ...logic, logic_type: v } ); } // No synthetic blank row — empty `rules: []` is valid; "Add rule" is the empty state. const rules = logic.rules; function updateRule( index, patch ) { commit( { ...logic, rules: rules.map( ( r, i ) => i === index ? { ...r, ...patch } : r ) } ); } function addRule() { commit( { ...logic, rules: [ ...rules, blankRule() ] } ); } function removeRule( index ) { commit( { ...logic, rules: rules.filter( ( _, i ) => i !== index ) } ); } function toggleEnabled( on ) { if ( on ) { setAttributes( { 'conditional-logic-enabled': 'yes', // Seed the envelope only — no starter rule. A source-less rule is // dropped by the write-path sanitizer anyway, so seeding one would // just make the editor and storage disagree; `rulesToRender` // supplies the blank row the user fills in. 'conditional-logic': attributes[ 'conditional-logic' ] || JSON.stringify( { action_type: 'show', logic_type: 'all', rules: [], } ), } ); } else { setAttributes( { 'conditional-logic-enabled': '' } ); } } return ( { enabled && ( <> { ! attributes.id && ( { __( 'Save the form once so this field gets an ID before referencing it from other rules.', 'profile-builder' ) } ) } { sourceFields.length === 0 && ( { __( 'No fields available as rule sources yet. Add a field to this form or to the global field list first.', 'profile-builder' ) } ) } { rules.length === 0 && sourceFields.length > 0 && (

{ __( 'No rules yet. Add a rule to control when this field is shown.', 'profile-builder' ) }

) } { rules.map( ( rule, index ) => { const sourceField = sourcesById[ Number( rule.field ) ]; const isNumericSource = sourceField && config.numericFieldTypes.includes( sourceField.fieldType ); const ruleFieldId = rule.field ? Number( rule.field ) : 0; // A rule references an off-form field when the picked // source isn't on the current canvas. Covers both: // (a) source picked from the global list that was never // added to this form; (b) source previously added and // since removed (the rule survives in attributes). const refsOffFormField = !! ruleFieldId && ( ! sourceField || ! sourceField.inForm ); // In-form vs off-form groups; disabled options are section headings. const formatLabel = ( f ) => `${ f.title } [${ f.fieldType }]`; const inFormSources = groupedSources.inForm; const offFormSources = groupedSources.offForm; const fieldOptions = [ { label: __( 'Choose…', 'profile-builder' ), value: '' }, ]; if ( inFormSources.length ) { // Only label the in-form group when there's an // off-form group to contrast against — otherwise // the heading is noise. if ( offFormSources.length ) { fieldOptions.push( { value: '__hdr_in_form__', label: '── ' + __( 'In this form', 'profile-builder' ) + ' ──', disabled: true, } ); } inFormSources.forEach( ( f ) => fieldOptions.push( { value: String( f.id ), label: formatLabel( f ), } ) ); } if ( offFormSources.length ) { // Always label the off-form group so the user // sees the boundary even if no in-form fields // exist yet (e.g. empty form, references picked // from the global list). fieldOptions.push( { value: '__hdr_off_form__', label: '── ' + __( 'Not in this form', 'profile-builder' ) + ' ──', disabled: true, } ); offFormSources.forEach( ( f ) => fieldOptions.push( { value: String( f.id ), label: formatLabel( f ), } ) ); } // Reference to a field that no longer exists anywhere // (deleted from the global list) — surface it in the // dropdown under its own heading so the user can see // what the rule pointed at and pick something else. if ( ruleFieldId && ! sourceField ) { fieldOptions.push( { value: '__hdr_missing__', label: '── ' + __( 'Missing field', 'profile-builder' ) + ' ──', disabled: true, } ); fieldOptions.push( { value: String( ruleFieldId ), label: `(${ __( 'missing field', 'profile-builder' ) } #${ ruleFieldId })`, } ); } const operatorLabels = OPERATOR_LABELS(); const operatorOptions = OPERATORS.map( ( op ) => ( { label: operatorLabels[ op ] || op, value: op, disabled: NUMERIC_OPERATORS.includes( op ) && sourceField && ! isNumericSource, } ) ); return (
.val()); the front-end readers compare loosely (==) // today, but storing a number is a latent robustness bug — any // future tightening to === / in_array(...,true) would silently // drop editor-authored rules. onChange={ ( val ) => updateRule( index, { field: val ? String( Number( val ) ) : '', value: '' } ) } /> { refsOffFormField && ( { sourceField ? __( 'This rule references a field that is not in this form, so the rule will never trigger for the form.', 'profile-builder' ) : __( 'This rule references a field that no longer exists. Pick a different field or remove the rule.', 'profile-builder' ) } ) } updateRule( index, { operator: val } ) } /> updateRule( index, { value: val } ) } />
); } ) } ) }
); }