| 1 |
import { __, sprintf } from '@wordpress/i18n'; |
| 2 |
import { useBlockProps } from '@wordpress/block-editor'; |
| 3 |
import { PanelBody, TextControl, SelectControl, TextareaControl, ToggleControl, Notice } from '@wordpress/components'; |
| 4 |
import { useRef, useEffect } from '@wordpress/element'; |
| 5 |
import { useSelect } from '@wordpress/data'; |
| 6 |
import CrossCuttingFieldPanels from './CrossCuttingFieldPanels'; |
| 7 |
import { useAllocateFieldId } from './useAllocateFieldId'; |
| 8 |
import { FieldSettingsFill } from './FieldSettingsSlotFill'; |
| 9 |
import { REPEATER_BLOCK_NAME } from '../lib/repeaterBlockName'; |
| 10 |
|
| 11 |
function collectBlockNames( blocks, selfClientId, out ) { |
| 12 |
for ( const b of blocks ) { |
| 13 |
if ( ! b ) continue; |
| 14 |
if ( b.clientId !== selfClientId ) { |
| 15 |
out.add( b.name ); |
| 16 |
} |
| 17 |
if ( Array.isArray( b.innerBlocks ) && b.innerBlocks.length ) { |
| 18 |
collectBlockNames( b.innerBlocks, selfClientId, out ); |
| 19 |
} |
| 20 |
} |
| 21 |
return out; |
| 22 |
} |
| 23 |
|
| 24 |
// Exclusive-group partners already on canvas (display labels). |
| 25 |
function findExclusiveConflicts( blockName, presentNames ) { |
| 26 |
const fb = ( typeof window !== 'undefined' && window.wppbFb ) || {}; |
| 27 |
const cfg = fb.uniqueness || {}; |
| 28 |
const groups = Array.isArray( cfg.exclusiveBlockGroups ) ? cfg.exclusiveBlockGroups : []; |
| 29 |
const blockToFieldType = ( fb.conditionalFields && fb.conditionalFields.blockToFieldType ) || {}; |
| 30 |
const conflicts = []; |
| 31 |
for ( const group of groups ) { |
| 32 |
if ( ! group.includes( blockName ) ) continue; |
| 33 |
for ( const other of group ) { |
| 34 |
if ( other !== blockName && presentNames.has( other ) ) { |
| 35 |
conflicts.push( blockToFieldType[ other ] || other ); |
| 36 |
} |
| 37 |
} |
| 38 |
} |
| 39 |
return conflicts; |
| 40 |
} |
| 41 |
|
| 42 |
// Collect meta-names for collision checks. Skips self + subtree. |
| 43 |
function collectMetaNamesForCollision( blocks, selfClientId, repeaterBlockName ) { |
| 44 |
const subMetaNames = []; |
| 45 |
const allMetaNames = []; |
| 46 |
const walk = ( list, insideRepeater ) => { |
| 47 |
for ( const b of list ) { |
| 48 |
if ( ! b || b.clientId === selfClientId ) continue; |
| 49 |
const meta = b.attributes && b.attributes[ 'meta-name' ]; |
| 50 |
if ( meta ) { |
| 51 |
allMetaNames.push( meta ); |
| 52 |
if ( insideRepeater ) subMetaNames.push( meta ); |
| 53 |
} |
| 54 |
if ( Array.isArray( b.innerBlocks ) && b.innerBlocks.length ) { |
| 55 |
const childInside = insideRepeater || b.name === repeaterBlockName; |
| 56 |
walk( b.innerBlocks, childInside ); |
| 57 |
} |
| 58 |
} |
| 59 |
}; |
| 60 |
walk( blocks, false ); |
| 61 |
return { subMetaNames, allMetaNames }; |
| 62 |
} |
| 63 |
|
| 64 |
const REGEX_SPECIAL = /[.*+?^${}()|[\]\\]/g; |
| 65 |
function escapeRegex( s ) { return s.replace( REGEX_SPECIAL, '\\$&' ); } |
| 66 |
|
| 67 |
// Runtime shape `<stem>_<setIndex>` — precompiled to avoid per-keystroke RegExps. |
| 68 |
const INDEXED_SUFFIX_RE = /^(.+)_[0-9]+$/; |
| 69 |
|
| 70 |
/** Mirror of wppb_fb_break_runtime_collision. Server appends `_pb` on clash. */ |
| 71 |
function detectRuntimeCollision( candidate, isSubField, subMetaNames, allMetaNames ) { |
| 72 |
if ( typeof candidate !== 'string' || candidate === '' ) return null; |
| 73 |
|
| 74 |
const candidateMatch = candidate.match( INDEXED_SUFFIX_RE ); |
| 75 |
if ( candidateMatch ) { |
| 76 |
const stem = candidateMatch[ 1 ]; |
| 77 |
for ( const sub of subMetaNames ) { |
| 78 |
if ( sub && sub === stem ) { |
| 79 |
return { direction: 'forward', conflictName: sub }; |
| 80 |
} |
| 81 |
} |
| 82 |
} |
| 83 |
|
| 84 |
if ( isSubField ) { |
| 85 |
for ( const other of allMetaNames ) { |
| 86 |
if ( ! other ) continue; |
| 87 |
const otherMatch = other.match( INDEXED_SUFFIX_RE ); |
| 88 |
if ( otherMatch && otherMatch[ 1 ] === candidate ) { |
| 89 |
return { direction: 'reverse', conflictName: other }; |
| 90 |
} |
| 91 |
} |
| 92 |
} |
| 93 |
|
| 94 |
return null; |
| 95 |
} |
| 96 |
|
| 97 |
function getMetaNameConfig() { |
| 98 |
const fb = ( typeof window !== 'undefined' && window.wppbFb ) || {}; |
| 99 |
const cfg = fb.metaNames || {}; |
| 100 |
const cf = fb.conditionalFields || {}; |
| 101 |
return { |
| 102 |
blockToFieldType: cf.blockToFieldType || {}, |
| 103 |
defaultFieldTypes: cfg.defaultFieldTypes || [], |
| 104 |
defaultMetaNames: cfg.defaultMetaNames || {}, |
| 105 |
fixedMetaNameFieldTypes: cfg.fixedMetaNameFieldTypes || [], |
| 106 |
noMetaNameFieldTypes: cfg.noMetaNameFieldTypes || [], |
| 107 |
noOverwriteFieldTypes: cfg.noOverwriteFieldTypes || [], |
| 108 |
reservedNames: cfg.reservedNames || [], |
| 109 |
// Empty if bridge missing — warning-only; server re-validates. |
| 110 |
reservedSubstrings: cfg.reservedSubstrings || [], |
| 111 |
customFieldPrefix: cfg.customFieldPrefix || 'custom_field_', |
| 112 |
maxLength: cfg.maxLength || 255, |
| 113 |
uploadFieldType: cfg.uploadFieldType || 'Upload', |
| 114 |
}; |
| 115 |
} |
| 116 |
|
| 117 |
/** Client-side meta-name checks; server still re-validates. */ |
| 118 |
function validateMetaName( value, fieldType, cfg ) { |
| 119 |
if ( typeof value !== 'string' ) value = ''; |
| 120 |
if ( value === '' ) return null; |
| 121 |
|
| 122 |
if ( /\s/.test( value ) ) { |
| 123 |
return __( 'Meta key cannot contain whitespace.', 'profile-builder' ); |
| 124 |
} |
| 125 |
if ( fieldType === cfg.uploadFieldType && ! /^[a-z0-9_\-]+$/.test( value ) ) { |
| 126 |
return __( 'Upload fields require lowercase letters, digits, underscores or hyphens only.', 'profile-builder' ); |
| 127 |
} |
| 128 |
if ( value.length > cfg.maxLength ) { |
| 129 |
return sprintf( |
| 130 |
/* translators: %d: maximum number of characters */ |
| 131 |
__( 'Meta key is too long (max %d characters).', 'profile-builder' ), |
| 132 |
cfg.maxLength |
| 133 |
); |
| 134 |
} |
| 135 |
if ( cfg.reservedNames.includes( value ) ) { |
| 136 |
return __( 'This meta key is reserved by WordPress and cannot be used.', 'profile-builder' ); |
| 137 |
} |
| 138 |
for ( const sub of cfg.reservedSubstrings ) { |
| 139 |
if ( value.toLowerCase().includes( sub ) ) { |
| 140 |
return sprintf( |
| 141 |
/* translators: %s: reserved substring */ |
| 142 |
__( '"%s" is reserved and cannot appear in a meta key.', 'profile-builder' ), |
| 143 |
sub |
| 144 |
); |
| 145 |
} |
| 146 |
} |
| 147 |
return null; |
| 148 |
} |
| 149 |
|
| 150 |
export default function BaseFieldEdit( { attributes, setAttributes, name, clientId, children, inspectorPanels = null, isSelected = false, hidePreview = false, hideLabel = false, hideDescription = false } ) { |
| 151 |
const cfg = getMetaNameConfig(); |
| 152 |
const fieldType = cfg.blockToFieldType[ name ] || ''; |
| 153 |
const isDefault = cfg.defaultFieldTypes.includes( fieldType ); |
| 154 |
// Fixed meta keys are shown read-only (classic parity). |
| 155 |
const isFixedMetaName = cfg.fixedMetaNameFieldTypes.includes( fieldType ); |
| 156 |
const isNoMetaName = cfg.noMetaNameFieldTypes.includes( fieldType ); |
| 157 |
const showsMetaName = ! isNoMetaName; |
| 158 |
const metaNameEditable = showsMetaName && ! isDefault && ! isFixedMetaName; |
| 159 |
// Overwrite toggle: storable non-default, minus classic deny list. |
| 160 |
const supportsOverwrite = showsMetaName && ! isDefault && ! cfg.noOverwriteFieldTypes.includes( fieldType ); |
| 161 |
|
| 162 |
// Cross-type exclusive groups (e.g. reCAPTCHA + Turnstile). Selected only. |
| 163 |
const exclusiveConflicts = useSelect( ( select ) => { |
| 164 |
if ( ! isSelected ) return []; |
| 165 |
const editor = select( 'core/block-editor' ); |
| 166 |
if ( ! editor ) return []; |
| 167 |
const top = editor.getBlocks(); |
| 168 |
const present = collectBlockNames( top, clientId, new Set() ); |
| 169 |
return findExclusiveConflicts( name, present ); |
| 170 |
}, [ name, clientId, isSelected ] ); |
| 171 |
|
| 172 |
// Only the selected block mounts FieldSettingsFill. |
| 173 |
const isBlockSelected = !! isSelected; |
| 174 |
|
| 175 |
const { id, 'field-title': title, 'meta-name': metaName, description, required, 'overwrite-existing': overwriteExisting } = attributes; |
| 176 |
|
| 177 |
const insideRepeater = useSelect( ( select ) => { |
| 178 |
const editor = select( 'core/block-editor' ); |
| 179 |
if ( ! editor || ! clientId ) return false; |
| 180 |
const parents = editor.getBlockParents( clientId ); |
| 181 |
for ( const parentClientId of parents ) { |
| 182 |
const parentBlock = editor.getBlock( parentClientId ); |
| 183 |
if ( parentBlock && parentBlock.name === REPEATER_BLOCK_NAME ) return true; |
| 184 |
} |
| 185 |
return false; |
| 186 |
}, [ clientId ] ); |
| 187 |
|
| 188 |
const runtimeCollision = useSelect( ( select ) => { |
| 189 |
if ( ! isSelected ) return null; |
| 190 |
if ( ! metaNameEditable ) return null; |
| 191 |
const candidate = attributes[ 'meta-name' ]; |
| 192 |
if ( ! candidate ) return null; |
| 193 |
const editor = select( 'core/block-editor' ); |
| 194 |
if ( ! editor ) return null; |
| 195 |
const { subMetaNames, allMetaNames } = collectMetaNamesForCollision( |
| 196 |
editor.getBlocks(), |
| 197 |
clientId, |
| 198 |
REPEATER_BLOCK_NAME |
| 199 |
); |
| 200 |
return detectRuntimeCollision( candidate, insideRepeater, subMetaNames, allMetaNames ); |
| 201 |
}, [ clientId, attributes[ 'meta-name' ], metaNameEditable, insideRepeater, isSelected ] ); |
| 202 |
|
| 203 |
// Last value the user typed (never from write-back) — for duplicate Notice. |
| 204 |
const userTypedMetaRef = useRef( undefined ); |
| 205 |
|
| 206 |
// Duplicate meta-name rewrite: recover the typed value for the Notice. |
| 207 |
const customFieldNameRe = new RegExp( '^' + escapeRegex( cfg.customFieldPrefix ) + '\\d+$' ); |
| 208 |
const replacedMetaName = ( |
| 209 |
metaNameEditable && |
| 210 |
overwriteExisting !== 'Yes' && |
| 211 |
userTypedMetaRef.current && |
| 212 |
userTypedMetaRef.current !== metaName && |
| 213 |
customFieldNameRe.test( metaName || '' ) |
| 214 |
) ? userTypedMetaRef.current : null; |
| 215 |
|
| 216 |
useAllocateFieldId( id, setAttributes ); |
| 217 |
|
| 218 |
// Baseline for mid-session rename Notice. |
| 219 |
const initialMetaNameRef = useRef( metaName ); |
| 220 |
useEffect( () => { |
| 221 |
if ( initialMetaNameRef.current === undefined && metaName !== undefined ) { |
| 222 |
initialMetaNameRef.current = metaName; |
| 223 |
} |
| 224 |
}, [ metaName ] ); |
| 225 |
|
| 226 |
const validationError = metaNameEditable ? validateMetaName( metaName, fieldType, cfg ) : null; |
| 227 |
const isRename = metaNameEditable && id > 0 && initialMetaNameRef.current !== undefined && metaName !== initialMetaNameRef.current && initialMetaNameRef.current !== ''; |
| 228 |
|
| 229 |
const placeholder = metaNameEditable |
| 230 |
? sprintf( |
| 231 |
/* translators: %s: example custom_field_? */ |
| 232 |
__( 'auto-generated on save (e.g. %s?)', 'profile-builder' ), |
| 233 |
cfg.customFieldPrefix |
| 234 |
) |
| 235 |
: ''; |
| 236 |
|
| 237 |
return ( |
| 238 |
<div { ...useBlockProps( { className: 'wppb-fb-field-block' } ) }> |
| 239 |
{ isBlockSelected && ( |
| 240 |
<FieldSettingsFill> |
| 241 |
<PanelBody title={ __( 'Field Settings', 'profile-builder' ) }> |
| 242 |
<TextControl |
| 243 |
label={ __( 'Field Title', 'profile-builder' ) } |
| 244 |
value={ title } |
| 245 |
onChange={ ( val ) => setAttributes( { 'field-title': val } ) } |
| 246 |
/> |
| 247 |
|
| 248 |
{ showsMetaName && metaNameEditable && ( |
| 249 |
<> |
| 250 |
<TextControl |
| 251 |
label={ __( 'Meta Name', 'profile-builder' ) } |
| 252 |
value={ metaName || '' } |
| 253 |
onChange={ ( val ) => { |
| 254 |
userTypedMetaRef.current = val; |
| 255 |
setAttributes( { 'meta-name': val } ); |
| 256 |
} } |
| 257 |
placeholder={ placeholder } |
| 258 |
help={ validationError || __( 'The wp_usermeta key used to store this field value. Leave blank to auto-generate.', 'profile-builder' ) } |
| 259 |
className={ validationError ? 'wppb-fb-meta-name-invalid' : undefined } |
| 260 |
/> |
| 261 |
{ isRename && ( |
| 262 |
<Notice status="warning" isDismissible={ false }> |
| 263 |
{ __( 'Renaming the meta key on a saved field leaves user data under the old key. Existing entries will not be migrated automatically.', 'profile-builder' ) } |
| 264 |
</Notice> |
| 265 |
) } |
| 266 |
{ runtimeCollision && ( |
| 267 |
<Notice status="warning" isDismissible={ false }> |
| 268 |
{ runtimeCollision.direction === 'forward' |
| 269 |
? sprintf( |
| 270 |
/* translators: 1: typed meta name, 2: conflicting sub-field meta name */ |
| 271 |
__( '"%1$s" matches the runtime pattern of Repeater sub-field "%2$s" (which writes to keys like %2$s_1, %2$s_2 …). On save the server will store this field as "%1$s_pb" to avoid clobbering that sub-field\'s data.', 'profile-builder' ), |
| 272 |
metaName, |
| 273 |
runtimeCollision.conflictName |
| 274 |
) |
| 275 |
: sprintf( |
| 276 |
/* translators: 1: typed sub-field meta name, 2: conflicting other meta name */ |
| 277 |
__( 'Sub-field "%1$s" would write to keys like %1$s_1, %1$s_2 … which conflicts with existing meta key "%2$s". On save the server will store this sub-field as "%1$s_pb" to avoid clobbering "%2$s".', 'profile-builder' ), |
| 278 |
metaName, |
| 279 |
runtimeCollision.conflictName |
| 280 |
) |
| 281 |
} |
| 282 |
</Notice> |
| 283 |
) } |
| 284 |
{ replacedMetaName && ! runtimeCollision && ( |
| 285 |
<Notice status="warning" isDismissible={ false }> |
| 286 |
{ sprintf( |
| 287 |
/* translators: 1: the (duplicate) meta key the user entered, 2: the auto-allocated meta key it was replaced with */ |
| 288 |
__( 'The meta key "%1$s" is already used by another field, so it was replaced with "%2$s" to keep keys unique. Enable "Overwrite existing user meta" below to intentionally share this key.', 'profile-builder' ), |
| 289 |
replacedMetaName, |
| 290 |
metaName |
| 291 |
) } |
| 292 |
</Notice> |
| 293 |
) } |
| 294 |
</> |
| 295 |
) } |
| 296 |
|
| 297 |
{ showsMetaName && ! metaNameEditable && ( |
| 298 |
<TextControl |
| 299 |
label={ __( 'Meta Name', 'profile-builder' ) } |
| 300 |
value={ metaName || cfg.defaultMetaNames[ fieldType ] || '' } |
| 301 |
disabled |
| 302 |
help={ isDefault |
| 303 |
? __( 'Default field — meta key is fixed and cannot be changed.', 'profile-builder' ) |
| 304 |
: __( 'This field type uses a fixed meta key that cannot be changed.', 'profile-builder' ) } |
| 305 |
onChange={ () => {} } |
| 306 |
/> |
| 307 |
) } |
| 308 |
|
| 309 |
{ supportsOverwrite && ( |
| 310 |
<ToggleControl |
| 311 |
label={ __( 'Overwrite existing user meta', 'profile-builder' ) } |
| 312 |
checked={ overwriteExisting === 'Yes' } |
| 313 |
onChange={ ( on ) => setAttributes( { 'overwrite-existing': on ? 'Yes' : 'No' } ) } |
| 314 |
help={ __( 'Allow saving even if the meta key already exists in wp_usermeta. Use with care — incoming submissions will overwrite stored values.', 'profile-builder' ) } |
| 315 |
/> |
| 316 |
) } |
| 317 |
|
| 318 |
{ description !== undefined && ! hideDescription && ( |
| 319 |
<TextareaControl |
| 320 |
label={ __( 'Description', 'profile-builder' ) } |
| 321 |
value={ description } |
| 322 |
onChange={ ( val ) => setAttributes( { description: val } ) } |
| 323 |
/> |
| 324 |
) } |
| 325 |
{ required !== undefined && ( |
| 326 |
<SelectControl |
| 327 |
label={ __( 'Required', 'profile-builder' ) } |
| 328 |
value={ required } |
| 329 |
options={ [ |
| 330 |
{ label: __( 'No', 'profile-builder' ), value: 'No' }, |
| 331 |
{ label: __( 'Yes', 'profile-builder' ), value: 'Yes' }, |
| 332 |
] } |
| 333 |
onChange={ ( val ) => setAttributes( { required: val } ) } |
| 334 |
/> |
| 335 |
) } |
| 336 |
</PanelBody> |
| 337 |
{ inspectorPanels } |
| 338 |
<CrossCuttingFieldPanels |
| 339 |
attributes={ attributes } |
| 340 |
setAttributes={ setAttributes } |
| 341 |
insideRepeater={ insideRepeater } |
| 342 |
/> |
| 343 |
</FieldSettingsFill> |
| 344 |
) } |
| 345 |
|
| 346 |
{ ! hideLabel && ( |
| 347 |
<div className="wppb-fb-field-label"> |
| 348 |
{ title } |
| 349 |
{ required === 'Yes' && <span className="wppb-fb-field-required">*</span> } |
| 350 |
</div> |
| 351 |
) } |
| 352 |
{ exclusiveConflicts.length > 0 && ( |
| 353 |
<Notice status="warning" isDismissible={ false }> |
| 354 |
{ sprintf( |
| 355 |
/* translators: %s: conflicting field type name */ |
| 356 |
__( 'This field cannot coexist with %s. The form will only keep one of them on save — remove the other to choose which.', 'profile-builder' ), |
| 357 |
exclusiveConflicts.join( ', ' ) |
| 358 |
) } |
| 359 |
</Notice> |
| 360 |
) } |
| 361 |
{ ! hidePreview && ( |
| 362 |
<div className="wppb-fb-field-preview"> |
| 363 |
{ children || <input type="text" disabled style={ { width: '100%' } } /> } |
| 364 |
</div> |
| 365 |
) } |
| 366 |
{ description && <div className="wppb-fb-field-desc">{ description }</div> } |
| 367 |
<div className="wppb-fb-field-meta"> |
| 368 |
{ fieldType && <span className="wppb-fb-field-tag">{ fieldType }</span> } |
| 369 |
<span className="wppb-fb-field-num"> |
| 370 |
{ id |
| 371 |
? sprintf( /* translators: %s: field id */ __( 'Field #%s', 'profile-builder' ), id ) |
| 372 |
: __( 'Field #…', 'profile-builder' ) } |
| 373 |
</span> |
| 374 |
</div> |
| 375 |
</div> |
| 376 |
); |
| 377 |
} |
| 378 |
|