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 / BaseFieldEdit.js

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

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