PluginProbe
User Profile Builder – Beautiful User Registration Forms, User Profiles & User Role Editor / 4.0.3
User Profile Builder – Beautiful User Registration Forms, User Profiles & User Role Editor v4.0.3
4.0.3 4.0.2 4.0.1 4.0.0 3.16.6 3.16.5 3.16.4 3.16.3 3.16.2 3.16.1 3.16.0 3.15.9 3.9.9 3.9.5 3.9.6 3.9.7 3.9.8 1.1.7 1.1.8 1.1.9 2.0.2 2.0.3 2.0.4 2.0.5 2.0.6 All 341 releases
profile-builder / form-builder / blocks / src / components / ConditionalLogicPanel.js

ConditionalLogicPanel.js in User Profile Builder – Beautiful User Registration Forms, User Profiles & User Role Editor 4.0.3, at form-builder/blocks/src/components/ConditionalLogicPanel.js

555 lines 23.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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