| 1 |
import { __ } from '@wordpress/i18n'; |
| 2 |
import { |
| 3 |
PanelBody, |
| 4 |
SelectControl, |
| 5 |
TextControl, |
| 6 |
ToggleControl, |
| 7 |
Button, |
| 8 |
Flex, |
| 9 |
FlexItem, |
| 10 |
Notice, |
| 11 |
} from '@wordpress/components'; |
| 12 |
import { useSelect } from '@wordpress/data'; |
| 13 |
import { useEffect, useState, useMemo } from '@wordpress/element'; |
| 14 |
|
| 15 |
import { useExistingFieldsSnapshot } from '../lib/existingFieldsSnapshot'; |
| 16 |
|
| 17 |
const OPERATORS = [ 'is', 'is not', 'less than', 'more than' ]; |
| 18 |
// The VALUES above are the stored enum and must never be translated — the |
| 19 |
// frontend compares them literally in features/conditional-fields/. Only the |
| 20 |
// dropdown label is localised, via this map. |
| 21 |
const OPERATOR_LABELS = () => ( { |
| 22 |
'is': __( 'is', 'profile-builder' ), |
| 23 |
'is not': __( 'is not', 'profile-builder' ), |
| 24 |
'less than': __( 'less than', 'profile-builder' ), |
| 25 |
'more than': __( 'more than', 'profile-builder' ), |
| 26 |
} ); |
| 27 |
const NUMERIC_OPERATORS = [ 'less than', 'more than' ]; |
| 28 |
|
| 29 |
const blankRule = () => ( { field: '', operator: 'is', value: '' } ); |
| 30 |
|
| 31 |
// Same inline plus as AddFieldButton's, for one visual language across the |
| 32 |
// editor's "add" affordances. Deliberately not @wordpress/icons — importing it |
| 33 |
// would pull a `wp-icons` script handle into the bundle's dependency list. |
| 34 |
const PlusIcon = () => ( |
| 35 |
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"> |
| 36 |
<path d="M12 5v14M5 12h14" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /> |
| 37 |
</svg> |
| 38 |
); |
| 39 |
|
| 40 |
/** |
| 41 |
* Reads window.wppbFb.conditionalFields with safe defaults so a missing localization |
| 42 |
* doesn't crash the editor — it just disables the source-field dropdown. |
| 43 |
*/ |
| 44 |
function getConfig() { |
| 45 |
const fb = ( typeof window !== 'undefined' && window.wppbFb ) || {}; |
| 46 |
const cfg = fb.conditionalFields || {}; |
| 47 |
return { |
| 48 |
blockToFieldType: cfg.blockToFieldType || {}, |
| 49 |
notAllowedAsSource: cfg.notAllowedAsSource || [], |
| 50 |
// [] and not a hardcoded copy of the server list — same reason as |
| 51 |
// reservedSubstrings in BaseFieldEdit: an absent bridge degrades to |
| 52 |
// "no numeric operators offered", never to a stale list. |
| 53 |
numericFieldTypes: cfg.numericFieldTypes || [], |
| 54 |
restRoot: cfg.restRoot || '', |
| 55 |
restNonce: cfg.restNonce || '', |
| 56 |
}; |
| 57 |
} |
| 58 |
|
| 59 |
/** |
| 60 |
* Walks the live editor block tree (recursive — repeater children included) and |
| 61 |
* returns a `Map(id → { title, fieldType, attributes })` of every block that has |
| 62 |
* been allocated an id. Used both to overlay fresh edits onto the global snapshot |
| 63 |
* and to compute the in-form flag for each candidate. |
| 64 |
*/ |
| 65 |
function collectCanvasMap( blocks, config, out = new Map() ) { |
| 66 |
for ( const block of blocks ) { |
| 67 |
const fieldType = config.blockToFieldType[ block.name ]; |
| 68 |
const id = block.attributes?.id; |
| 69 |
if ( fieldType && id ) { |
| 70 |
out.set( id, { |
| 71 |
title: block.attributes[ 'field-title' ] || `(field #${ id })`, |
| 72 |
fieldType, |
| 73 |
attributes: block.attributes, |
| 74 |
} ); |
| 75 |
} |
| 76 |
if ( block.innerBlocks && block.innerBlocks.length ) { |
| 77 |
collectCanvasMap( block.innerBlocks, config, out ); |
| 78 |
} |
| 79 |
} |
| 80 |
return out; |
| 81 |
} |
| 82 |
|
| 83 |
/** |
| 84 |
* Merge global field snapshot with live canvas into CL rule sources. |
| 85 |
* Canvas wins on overlap; each entry has `inForm` for the off-form warning. |
| 86 |
*/ |
| 87 |
function mergeSourceFields( snapshotEntries, canvasMap, config, currentId ) { |
| 88 |
const seen = new Set(); |
| 89 |
const out = []; |
| 90 |
|
| 91 |
const accept = ( id, fieldType ) => ( |
| 92 |
id && |
| 93 |
id !== currentId && |
| 94 |
fieldType && |
| 95 |
! config.notAllowedAsSource.includes( fieldType ) |
| 96 |
); |
| 97 |
|
| 98 |
canvasMap.forEach( ( info, id ) => { |
| 99 |
if ( ! accept( id, info.fieldType ) ) return; |
| 100 |
seen.add( id ); |
| 101 |
out.push( { |
| 102 |
id, |
| 103 |
title: info.title, |
| 104 |
fieldType: info.fieldType, |
| 105 |
attributes: info.attributes, |
| 106 |
inForm: true, |
| 107 |
} ); |
| 108 |
} ); |
| 109 |
|
| 110 |
for ( const entry of snapshotEntries ) { |
| 111 |
const id = entry.id; |
| 112 |
if ( seen.has( id ) ) continue; |
| 113 |
if ( ! accept( id, entry.fieldType ) ) continue; |
| 114 |
out.push( { |
| 115 |
id, |
| 116 |
title: entry.title || `(field #${ id })`, |
| 117 |
fieldType: entry.fieldType, |
| 118 |
attributes: entry.attributes || {}, |
| 119 |
inForm: false, |
| 120 |
} ); |
| 121 |
} |
| 122 |
|
| 123 |
return out; |
| 124 |
} |
| 125 |
|
| 126 |
/** |
| 127 |
* Parses the rules JSON stored on the block, normalising the legacy shape (rules can |
| 128 |
* be either an object or array on disk) into a plain array. |
| 129 |
*/ |
| 130 |
function parseLogic( raw ) { |
| 131 |
if ( ! raw ) { |
| 132 |
return { action_type: 'show', logic_type: 'all', rules: [] }; |
| 133 |
} |
| 134 |
let parsed; |
| 135 |
try { |
| 136 |
parsed = JSON.parse( raw ); |
| 137 |
} catch ( e ) { |
| 138 |
return { action_type: 'show', logic_type: 'all', rules: [] }; |
| 139 |
} |
| 140 |
let rules = parsed.rules; |
| 141 |
if ( rules && ! Array.isArray( rules ) ) { |
| 142 |
rules = Object.keys( rules ).map( ( k ) => rules[ k ] ); |
| 143 |
} |
| 144 |
return { |
| 145 |
action_type: parsed.action_type === 'hide' ? 'hide' : 'show', |
| 146 |
logic_type: parsed.logic_type === 'any' ? 'any' : 'all', |
| 147 |
rules: Array.isArray( rules ) ? rules : [], |
| 148 |
}; |
| 149 |
} |
| 150 |
|
| 151 |
/** |
| 152 |
* Value control for a rule: Select or TextControl from the source field's options. |
| 153 |
* CPT/Taxonomy/User Role/Country/Currency load options from REST (ISO codes need labels). |
| 154 |
*/ |
| 155 |
function RuleValueControl( { sourceField, value, onChange } ) { |
| 156 |
const config = getConfig(); |
| 157 |
const [ remoteOptions, setRemoteOptions ] = useState( null ); |
| 158 |
const [ loading, setLoading ] = useState( false ); |
| 159 |
|
| 160 |
const enriched = sourceField && [ |
| 161 |
'Select (CPT)', |
| 162 |
'Select (Taxonomy)', |
| 163 |
'Select (User Role)', |
| 164 |
'Select (Country)', |
| 165 |
'Select (Currency)', |
| 166 |
].includes( sourceField.fieldType ); |
| 167 |
|
| 168 |
const enrichmentKey = enriched |
| 169 |
? sourceField.fieldType + '|' + ( |
| 170 |
sourceField.attributes?.cpt |
| 171 |
|| sourceField.attributes?.taxonomy |
| 172 |
|| sourceField.attributes?.[ 'user-roles' ] |
| 173 |
|| '' |
| 174 |
) |
| 175 |
: null; |
| 176 |
|
| 177 |
useEffect( () => { |
| 178 |
if ( ! enriched || ! enrichmentKey || ! config.restRoot ) { |
| 179 |
setRemoteOptions( null ); |
| 180 |
return undefined; |
| 181 |
} |
| 182 |
// Cancellation guard for a fast source-field switch: a new fetch fires |
| 183 |
// while the previous is in flight, and an older response can resolve |
| 184 |
// last, overwriting `remoteOptions`/`loading` with stale data for the |
| 185 |
// wrong source field. Ignore any response whose effect run was |
| 186 |
// superseded / unmounted. |
| 187 |
let cancelled = false; |
| 188 |
setLoading( true ); |
| 189 |
const params = new URLSearchParams( { field_type: sourceField.fieldType } ); |
| 190 |
if ( sourceField.attributes?.cpt ) params.set( 'cpt', sourceField.attributes.cpt ); |
| 191 |
if ( sourceField.attributes?.taxonomy ) params.set( 'taxonomy', sourceField.attributes.taxonomy ); |
| 192 |
if ( sourceField.attributes?.[ 'user-roles' ] ) params.set( 'user_roles', sourceField.attributes[ 'user-roles' ] ); |
| 193 |
|
| 194 |
fetch( config.restRoot + '?' + params.toString(), { |
| 195 |
credentials: 'same-origin', |
| 196 |
headers: { 'X-WP-Nonce': config.restNonce }, |
| 197 |
} ) |
| 198 |
.then( ( r ) => r.json() ) |
| 199 |
.then( ( data ) => { if ( ! cancelled ) setRemoteOptions( data && data.values ? data : { values: [], labels: [] } ); } ) |
| 200 |
.catch( () => { if ( ! cancelled ) setRemoteOptions( { values: [], labels: [] } ); } ) |
| 201 |
.finally( () => { if ( ! cancelled ) setLoading( false ); } ); |
| 202 |
|
| 203 |
return () => { cancelled = true; }; |
| 204 |
}, [ enrichmentKey ] ); |
| 205 |
|
| 206 |
if ( ! sourceField ) { |
| 207 |
return ( |
| 208 |
<TextControl |
| 209 |
label={ __( 'Value', 'profile-builder' ) } |
| 210 |
value={ value } |
| 211 |
onChange={ onChange } |
| 212 |
/> |
| 213 |
); |
| 214 |
} |
| 215 |
|
| 216 |
// Static options coming from the block's `options` attribute (Select/Radio/Checkbox). |
| 217 |
let staticValues = []; |
| 218 |
let staticLabels = []; |
| 219 |
const optsRaw = sourceField.attributes?.options; |
| 220 |
if ( typeof optsRaw === 'string' && optsRaw.trim() !== '' ) { |
| 221 |
staticValues = optsRaw.split( ',' ).map( ( v ) => v.trim() ); |
| 222 |
const labelsRaw = sourceField.attributes?.labels; |
| 223 |
staticLabels = ( typeof labelsRaw === 'string' && labelsRaw.trim() !== '' ) |
| 224 |
? labelsRaw.split( ',' ).map( ( v ) => v.trim() ) |
| 225 |
: staticValues; |
| 226 |
} |
| 227 |
|
| 228 |
const remoteValues = remoteOptions?.values || []; |
| 229 |
const remoteLabels = remoteOptions?.labels || []; |
| 230 |
|
| 231 |
if ( enriched ) { |
| 232 |
if ( loading ) { |
| 233 |
return <TextControl label={ __( 'Value', 'profile-builder' ) } value={ value } disabled />; |
| 234 |
} |
| 235 |
if ( remoteValues.length === 0 ) { |
| 236 |
return ( |
| 237 |
<TextControl |
| 238 |
label={ __( 'Value', 'profile-builder' ) } |
| 239 |
value={ value } |
| 240 |
onChange={ onChange } |
| 241 |
help={ __( 'No options resolved from server. Type the value/ID directly.', 'profile-builder' ) } |
| 242 |
/> |
| 243 |
); |
| 244 |
} |
| 245 |
const options = [ |
| 246 |
{ label: __( 'Choose…', 'profile-builder' ), value: '' }, |
| 247 |
...remoteValues.map( ( v, i ) => ( { value: v, label: remoteLabels[ i ] || v } ) ), |
| 248 |
]; |
| 249 |
return ( |
| 250 |
<SelectControl |
| 251 |
label={ __( 'Value', 'profile-builder' ) } |
| 252 |
value={ value } |
| 253 |
options={ options } |
| 254 |
onChange={ onChange } |
| 255 |
/> |
| 256 |
); |
| 257 |
} |
| 258 |
|
| 259 |
if ( staticValues.length > 0 ) { |
| 260 |
const options = [ |
| 261 |
{ label: __( 'Choose…', 'profile-builder' ), value: '' }, |
| 262 |
...staticValues.map( ( v, i ) => ( { value: v, label: staticLabels[ i ] || v } ) ), |
| 263 |
]; |
| 264 |
return ( |
| 265 |
<SelectControl |
| 266 |
label={ __( 'Value', 'profile-builder' ) } |
| 267 |
value={ value } |
| 268 |
options={ options } |
| 269 |
onChange={ onChange } |
| 270 |
/> |
| 271 |
); |
| 272 |
} |
| 273 |
|
| 274 |
return ( |
| 275 |
<TextControl |
| 276 |
label={ __( 'Value', 'profile-builder' ) } |
| 277 |
value={ value } |
| 278 |
onChange={ onChange } |
| 279 |
/> |
| 280 |
); |
| 281 |
} |
| 282 |
|
| 283 |
export default function ConditionalLogicPanel( { attributes, setAttributes } ) { |
| 284 |
const config = getConfig(); |
| 285 |
const enabled = attributes[ 'conditional-logic-enabled' ] === 'yes'; |
| 286 |
const logic = useMemo( () => parseLogic( attributes[ 'conditional-logic' ] ), [ attributes[ 'conditional-logic' ] ] ); |
| 287 |
|
| 288 |
// Live canvas: in-form set + title overlay on the global snapshot. |
| 289 |
const canvasMap = useSelect( ( select ) => { |
| 290 |
const blocks = select( 'core/block-editor' ).getBlocks(); |
| 291 |
return collectCanvasMap( blocks, config ); |
| 292 |
}, [] ); |
| 293 |
|
| 294 |
// Global live snapshot (not window.wppbFb — that stays frozen at page load). |
| 295 |
// Top-level rows only; sub-fields are not enforceable as CL sources. |
| 296 |
const snapshotEntries = useExistingFieldsSnapshot(); |
| 297 |
const sourceFields = useMemo( |
| 298 |
() => mergeSourceFields( snapshotEntries, canvasMap, config, attributes.id ), |
| 299 |
[ snapshotEntries, canvasMap, attributes.id ] |
| 300 |
); |
| 301 |
|
| 302 |
const sourcesById = useMemo( () => { |
| 303 |
const m = {}; |
| 304 |
sourceFields.forEach( ( f ) => { m[ f.id ] = f; } ); |
| 305 |
return m; |
| 306 |
}, [ sourceFields ] ); |
| 307 |
|
| 308 |
// Split + sort sources once for every rule's dropdown. |
| 309 |
const groupedSources = useMemo( () => { |
| 310 |
const byTitle = ( a, b ) => a.title.localeCompare( b.title ); |
| 311 |
return { |
| 312 |
inForm: sourceFields.filter( ( f ) => f.inForm ).sort( byTitle ), |
| 313 |
offForm: sourceFields.filter( ( f ) => ! f.inForm ).sort( byTitle ), |
| 314 |
}; |
| 315 |
}, [ sourceFields ] ); |
| 316 |
|
| 317 |
function commit( next ) { |
| 318 |
setAttributes( { 'conditional-logic': JSON.stringify( next ) } ); |
| 319 |
} |
| 320 |
|
| 321 |
function setActionType( v ) { commit( { ...logic, action_type: v } ); } |
| 322 |
function setLogicType( v ) { commit( { ...logic, logic_type: v } ); } |
| 323 |
|
| 324 |
// No synthetic blank row — empty `rules: []` is valid; "Add rule" is the empty state. |
| 325 |
const rules = logic.rules; |
| 326 |
|
| 327 |
function updateRule( index, patch ) { |
| 328 |
commit( { ...logic, rules: rules.map( ( r, i ) => i === index ? { ...r, ...patch } : r ) } ); |
| 329 |
} |
| 330 |
function addRule() { |
| 331 |
commit( { ...logic, rules: [ ...rules, blankRule() ] } ); |
| 332 |
} |
| 333 |
function removeRule( index ) { |
| 334 |
commit( { ...logic, rules: rules.filter( ( _, i ) => i !== index ) } ); |
| 335 |
} |
| 336 |
|
| 337 |
function toggleEnabled( on ) { |
| 338 |
if ( on ) { |
| 339 |
setAttributes( { |
| 340 |
'conditional-logic-enabled': 'yes', |
| 341 |
// Seed the envelope only — no starter rule. A source-less rule is |
| 342 |
// dropped by the write-path sanitizer anyway, so seeding one would |
| 343 |
// just make the editor and storage disagree; `rulesToRender` |
| 344 |
// supplies the blank row the user fills in. |
| 345 |
'conditional-logic': attributes[ 'conditional-logic' ] || JSON.stringify( { |
| 346 |
action_type: 'show', |
| 347 |
logic_type: 'all', |
| 348 |
rules: [], |
| 349 |
} ), |
| 350 |
} ); |
| 351 |
} else { |
| 352 |
setAttributes( { 'conditional-logic-enabled': '' } ); |
| 353 |
} |
| 354 |
} |
| 355 |
|
| 356 |
return ( |
| 357 |
<PanelBody title={ __( 'Conditional Logic', 'profile-builder' ) } initialOpen={ false }> |
| 358 |
<ToggleControl |
| 359 |
label={ __( 'Enable conditional logic', 'profile-builder' ) } |
| 360 |
checked={ enabled } |
| 361 |
onChange={ toggleEnabled } |
| 362 |
/> |
| 363 |
|
| 364 |
{ enabled && ( |
| 365 |
<> |
| 366 |
{ ! attributes.id && ( |
| 367 |
<Notice status="warning" isDismissible={ false }> |
| 368 |
{ __( 'Save the form once so this field gets an ID before referencing it from other rules.', 'profile-builder' ) } |
| 369 |
</Notice> |
| 370 |
) } |
| 371 |
|
| 372 |
<Flex> |
| 373 |
<FlexItem> |
| 374 |
<SelectControl |
| 375 |
label={ __( 'Action', 'profile-builder' ) } |
| 376 |
value={ logic.action_type } |
| 377 |
options={ [ |
| 378 |
{ label: __( 'Show', 'profile-builder' ), value: 'show' }, |
| 379 |
{ label: __( 'Hide', 'profile-builder' ), value: 'hide' }, |
| 380 |
] } |
| 381 |
onChange={ setActionType } |
| 382 |
/> |
| 383 |
</FlexItem> |
| 384 |
<FlexItem> |
| 385 |
<SelectControl |
| 386 |
label={ __( 'Match', 'profile-builder' ) } |
| 387 |
value={ logic.logic_type } |
| 388 |
options={ [ |
| 389 |
{ label: __( 'All rules', 'profile-builder' ), value: 'all' }, |
| 390 |
{ label: __( 'Any rule', 'profile-builder' ), value: 'any' }, |
| 391 |
] } |
| 392 |
onChange={ setLogicType } |
| 393 |
/> |
| 394 |
</FlexItem> |
| 395 |
</Flex> |
| 396 |
|
| 397 |
{ sourceFields.length === 0 && ( |
| 398 |
<Notice status="info" isDismissible={ false }> |
| 399 |
{ __( 'No fields available as rule sources yet. Add a field to this form or to the global field list first.', 'profile-builder' ) } |
| 400 |
</Notice> |
| 401 |
) } |
| 402 |
|
| 403 |
{ rules.length === 0 && sourceFields.length > 0 && ( |
| 404 |
<p style={ { margin: '8px 0 0', color: '#757575' } }> |
| 405 |
{ __( 'No rules yet. Add a rule to control when this field is shown.', 'profile-builder' ) } |
| 406 |
</p> |
| 407 |
) } |
| 408 |
|
| 409 |
{ rules.map( ( rule, index ) => { |
| 410 |
const sourceField = sourcesById[ Number( rule.field ) ]; |
| 411 |
const isNumericSource = sourceField && config.numericFieldTypes.includes( sourceField.fieldType ); |
| 412 |
const ruleFieldId = rule.field ? Number( rule.field ) : 0; |
| 413 |
// A rule references an off-form field when the picked |
| 414 |
// source isn't on the current canvas. Covers both: |
| 415 |
// (a) source picked from the global list that was never |
| 416 |
// added to this form; (b) source previously added and |
| 417 |
// since removed (the rule survives in attributes). |
| 418 |
const refsOffFormField = !! ruleFieldId && ( |
| 419 |
! sourceField || ! sourceField.inForm |
| 420 |
); |
| 421 |
|
| 422 |
// In-form vs off-form groups; disabled options are section headings. |
| 423 |
const formatLabel = ( f ) => `${ f.title } [${ f.fieldType }]`; |
| 424 |
const inFormSources = groupedSources.inForm; |
| 425 |
const offFormSources = groupedSources.offForm; |
| 426 |
|
| 427 |
const fieldOptions = [ |
| 428 |
{ label: __( 'Choose…', 'profile-builder' ), value: '' }, |
| 429 |
]; |
| 430 |
|
| 431 |
if ( inFormSources.length ) { |
| 432 |
// Only label the in-form group when there's an |
| 433 |
// off-form group to contrast against — otherwise |
| 434 |
// the heading is noise. |
| 435 |
if ( offFormSources.length ) { |
| 436 |
fieldOptions.push( { |
| 437 |
value: '__hdr_in_form__', |
| 438 |
label: '── ' + __( 'In this form', 'profile-builder' ) + ' ──', |
| 439 |
disabled: true, |
| 440 |
} ); |
| 441 |
} |
| 442 |
inFormSources.forEach( ( f ) => fieldOptions.push( { |
| 443 |
value: String( f.id ), |
| 444 |
label: formatLabel( f ), |
| 445 |
} ) ); |
| 446 |
} |
| 447 |
|
| 448 |
if ( offFormSources.length ) { |
| 449 |
// Always label the off-form group so the user |
| 450 |
// sees the boundary even if no in-form fields |
| 451 |
// exist yet (e.g. empty form, references picked |
| 452 |
// from the global list). |
| 453 |
fieldOptions.push( { |
| 454 |
value: '__hdr_off_form__', |
| 455 |
label: '── ' + __( 'Not in this form', 'profile-builder' ) + ' ──', |
| 456 |
disabled: true, |
| 457 |
} ); |
| 458 |
offFormSources.forEach( ( f ) => fieldOptions.push( { |
| 459 |
value: String( f.id ), |
| 460 |
label: formatLabel( f ), |
| 461 |
} ) ); |
| 462 |
} |
| 463 |
|
| 464 |
// Reference to a field that no longer exists anywhere |
| 465 |
// (deleted from the global list) — surface it in the |
| 466 |
// dropdown under its own heading so the user can see |
| 467 |
// what the rule pointed at and pick something else. |
| 468 |
if ( ruleFieldId && ! sourceField ) { |
| 469 |
fieldOptions.push( { |
| 470 |
value: '__hdr_missing__', |
| 471 |
label: '── ' + __( 'Missing field', 'profile-builder' ) + ' ──', |
| 472 |
disabled: true, |
| 473 |
} ); |
| 474 |
fieldOptions.push( { |
| 475 |
value: String( ruleFieldId ), |
| 476 |
label: `(${ __( 'missing field', 'profile-builder' ) } #${ ruleFieldId })`, |
| 477 |
} ); |
| 478 |
} |
| 479 |
|
| 480 |
const operatorLabels = OPERATOR_LABELS(); |
| 481 |
const operatorOptions = OPERATORS.map( ( op ) => ( { |
| 482 |
label: operatorLabels[ op ] || op, |
| 483 |
value: op, |
| 484 |
disabled: NUMERIC_OPERATORS.includes( op ) && sourceField && ! isNumericSource, |
| 485 |
} ) ); |
| 486 |
|
| 487 |
return ( |
| 488 |
<div |
| 489 |
key={ index } |
| 490 |
style={ { |
| 491 |
border: '1px solid #ddd', |
| 492 |
padding: '8px', |
| 493 |
marginTop: '8px', |
| 494 |
borderRadius: '3px', |
| 495 |
} } |
| 496 |
> |
| 497 |
<SelectControl |
| 498 |
label={ __( 'Field', 'profile-builder' ) } |
| 499 |
value={ rule.field ? String( rule.field ) : '' } |
| 500 |
options={ fieldOptions } |
| 501 |
// Store `field` as a STRING to match the legacy canonical |
| 502 |
// shape ("291", not 291). Classic-authored rules write strings |
| 503 |
// (<select>.val()); the front-end readers compare loosely (==) |
| 504 |
// today, but storing a number is a latent robustness bug — any |
| 505 |
// future tightening to === / in_array(...,true) would silently |
| 506 |
// drop editor-authored rules. |
| 507 |
onChange={ ( val ) => updateRule( index, { field: val ? String( Number( val ) ) : '', value: '' } ) } |
| 508 |
/> |
| 509 |
{ refsOffFormField && ( |
| 510 |
<Notice status="warning" isDismissible={ false }> |
| 511 |
{ sourceField |
| 512 |
? __( 'This rule references a field that is not in this form, so the rule will never trigger for the form.', 'profile-builder' ) |
| 513 |
: __( 'This rule references a field that no longer exists. Pick a different field or remove the rule.', 'profile-builder' ) |
| 514 |
} |
| 515 |
</Notice> |
| 516 |
) } |
| 517 |
<SelectControl |
| 518 |
label={ __( 'Operator', 'profile-builder' ) } |
| 519 |
value={ rule.operator || 'is' } |
| 520 |
options={ operatorOptions } |
| 521 |
onChange={ ( val ) => updateRule( index, { operator: val } ) } |
| 522 |
/> |
| 523 |
<RuleValueControl |
| 524 |
sourceField={ sourceField } |
| 525 |
value={ rule.value || '' } |
| 526 |
onChange={ ( val ) => updateRule( index, { value: val } ) } |
| 527 |
/> |
| 528 |
<Flex justify="flex-end" style={ { marginTop: '8px' } }> |
| 529 |
<Button |
| 530 |
variant="tertiary" |
| 531 |
size="small" |
| 532 |
isDestructive |
| 533 |
onClick={ () => removeRule( index ) } |
| 534 |
> |
| 535 |
{ __( 'Remove', 'profile-builder' ) } |
| 536 |
</Button> |
| 537 |
</Flex> |
| 538 |
</div> |
| 539 |
); |
| 540 |
} ) } |
| 541 |
|
| 542 |
<Button |
| 543 |
variant="secondary" |
| 544 |
icon={ <PlusIcon /> } |
| 545 |
onClick={ addRule } |
| 546 |
style={ { marginTop: '12px' } } |
| 547 |
> |
| 548 |
{ __( 'Add rule', 'profile-builder' ) } |
| 549 |
</Button> |
| 550 |
</> |
| 551 |
) } |
| 552 |
</PanelBody> |
| 553 |
); |
| 554 |
} |
| 555 |
|