| 1 |
import { __, sprintf } from '@wordpress/i18n'; |
| 2 |
import { useBlockProps, InnerBlocks } from '@wordpress/block-editor'; |
| 3 |
import { |
| 4 |
PanelBody, |
| 5 |
TextControl, |
| 6 |
TextareaControl, |
| 7 |
CheckboxControl, |
| 8 |
BaseControl, |
| 9 |
} from '@wordpress/components'; |
| 10 |
import { useMemo } from '@wordpress/element'; |
| 11 |
import { useAllocateFieldId } from '../../components/useAllocateFieldId'; |
| 12 |
import { FieldSettingsFill } from '../../components/FieldSettingsSlotFill'; |
| 13 |
import CrossCuttingFieldPanels from '../../components/CrossCuttingFieldPanels'; |
| 14 |
import AddFieldButton from '../../components/AddFieldButton'; |
| 15 |
|
| 16 |
const LIMIT_MESSAGE_DEFAULT = 'The maximum number of fields has been reached.'; |
| 17 |
|
| 18 |
/** |
| 19 |
* Decode the legacy rpf-role-limit JSON shape: |
| 20 |
* {"rules":[{"role":"subscriber","value":"5"}, …]} |
| 21 |
* |
| 22 |
* Returns a flat { role => stringValue } map for editor UI consumption. |
| 23 |
* Tolerant of missing / malformed input — returns an empty object. |
| 24 |
*/ |
| 25 |
function decodeRoleLimitRules( raw ) { |
| 26 |
if ( ! raw ) return {}; |
| 27 |
try { |
| 28 |
const parsed = JSON.parse( raw ); |
| 29 |
if ( ! parsed || ! Array.isArray( parsed.rules ) ) return {}; |
| 30 |
return parsed.rules.reduce( ( acc, rule ) => { |
| 31 |
if ( rule && typeof rule.role === 'string' ) { |
| 32 |
acc[ rule.role ] = String( rule.value ?? '' ); |
| 33 |
} |
| 34 |
return acc; |
| 35 |
}, {} ); |
| 36 |
} catch ( _ ) { |
| 37 |
return {}; |
| 38 |
} |
| 39 |
} |
| 40 |
|
| 41 |
/** |
| 42 |
* Re-encode a { role => stringValue } map into the legacy JSON shape. |
| 43 |
* Returns an empty string when no rules survive — matches the legacy |
| 44 |
* "no rules set" sentinel that the front-end short-circuits on. |
| 45 |
*/ |
| 46 |
function encodeRoleLimitRules( ruleMap ) { |
| 47 |
const rules = Object.entries( ruleMap ) |
| 48 |
.filter( ( [ , value ] ) => value !== '' && value !== undefined ) |
| 49 |
.map( ( [ role, value ] ) => ( { role, value: String( value ) } ) ); |
| 50 |
return rules.length ? JSON.stringify( { rules } ) : ''; |
| 51 |
} |
| 52 |
|
| 53 |
export default function Edit( { attributes, setAttributes, clientId, isSelected = false } ) { |
| 54 |
const { |
| 55 |
id, |
| 56 |
'field-title': title, |
| 57 |
description, |
| 58 |
required, |
| 59 |
'rpf-enable-limit': enableLimit, |
| 60 |
'rpf-limit': limit, |
| 61 |
'rpf-limit-reached-message': limitReachedMessage, |
| 62 |
'rpf-role-limit': roleLimitJson, |
| 63 |
} = attributes; |
| 64 |
|
| 65 |
// Same selection gate as BaseFieldEdit — only the selected block's Fill |
| 66 |
// populates the Field Settings PluginSidebar slot. Gutenberg passes |
| 67 |
// isSelected for real blocks; VirtualFieldEdit (Existing Fields panel) |
| 68 |
// passes it explicitly for not-in-form previews. |
| 69 |
const isBlockSelected = !! isSelected; |
| 70 |
|
| 71 |
// Allowed sub-field blocks come from the server bridge — registry minus |
| 72 |
// wppb_rpf_manage_fields_get_excluded_fields(). Fallback to a single safe |
| 73 |
// type if the bridge isn't loaded (e.g. add-on disabled mid-session). |
| 74 |
const repeaterBridge = ( window.wppbFb && window.wppbFb.repeater ) || {}; |
| 75 |
const ALLOWED_BLOCKS = ( Array.isArray( repeaterBridge.allowedBlocks ) && repeaterBridge.allowedBlocks.length ) |
| 76 |
? repeaterBridge.allowedBlocks |
| 77 |
: [ 'profile-builder/field-input' ]; |
| 78 |
const limitEnabled = enableLimit === 'yes'; |
| 79 |
|
| 80 |
// ID handshake — see BaseFieldEdit for rationale. |
| 81 |
useAllocateFieldId( id, setAttributes ); |
| 82 |
|
| 83 |
// Role list from the form-builder bridge (window.wppbFb.formSettings.roles). |
| 84 |
// Strip the "Default Role" sentinel — it's a registration-form Set-Role |
| 85 |
// option, not a real role, and per-role limits don't apply to it. |
| 86 |
const allRoles = useMemo( () => { |
| 87 |
const bridge = ( window.wppbFb && window.wppbFb.formSettings && window.wppbFb.formSettings.roles ) || []; |
| 88 |
return bridge.filter( ( opt ) => opt.value !== 'default role' ); |
| 89 |
}, [] ); |
| 90 |
|
| 91 |
const roleLimitMap = useMemo( () => decodeRoleLimitRules( roleLimitJson ), [ roleLimitJson ] ); |
| 92 |
|
| 93 |
const setRoleLimit = ( roleSlug, value ) => { |
| 94 |
const next = { ...roleLimitMap, [ roleSlug ]: value }; |
| 95 |
setAttributes( { 'rpf-role-limit': encodeRoleLimitRules( next ) } ); |
| 96 |
}; |
| 97 |
|
| 98 |
return ( |
| 99 |
<div { ...useBlockProps( { className: 'wppb-fb-field-block wppb-fb-repeater-block' } ) }> |
| 100 |
{ isBlockSelected && ( |
| 101 |
<FieldSettingsFill> |
| 102 |
<PanelBody title={ __( 'Repeater Settings', 'profile-builder' ) }> |
| 103 |
<TextControl |
| 104 |
label={ __( 'Field Title', 'profile-builder' ) } |
| 105 |
value={ title } |
| 106 |
onChange={ ( val ) => setAttributes( { 'field-title': val } ) } |
| 107 |
/> |
| 108 |
{ /* No `rpf-button` control by design. |
| 109 |
The classic `.row-rpf-button` row is not a stored label — |
| 110 |
it's a WCK custom type that renders an "Edit field group" |
| 111 |
button opening the sub-field iframe |
| 112 |
(add-ons/repeater-field/admin/repeater-manage-fields.php:280-285), |
| 113 |
and nothing on the front end ever reads the key. The |
| 114 |
InnerBlocks canvas below plus "Add sub-field" IS that |
| 115 |
affordance here, so the attribute was dropped from |
| 116 |
block.json rather than given a control that would store a |
| 117 |
value no renderer consumes. */ } |
| 118 |
</PanelBody> |
| 119 |
|
| 120 |
<PanelBody title={ __( 'Limit', 'profile-builder' ) } initialOpen={ false }> |
| 121 |
<CheckboxControl |
| 122 |
label={ __( 'Enable limit', 'profile-builder' ) } |
| 123 |
checked={ limitEnabled } |
| 124 |
onChange={ ( checked ) => setAttributes( { 'rpf-enable-limit': checked ? 'yes' : '' } ) } |
| 125 |
help={ __( 'Enable a limit on how many repeater groups users can add on the front-end.', 'profile-builder' ) } |
| 126 |
/> |
| 127 |
{ limitEnabled && ( |
| 128 |
<> |
| 129 |
<TextControl |
| 130 |
label={ __( 'General Limit', 'profile-builder' ) } |
| 131 |
type="number" |
| 132 |
min={ 0 } |
| 133 |
step={ 1 } |
| 134 |
value={ limit ?? '0' } |
| 135 |
onChange={ ( val ) => setAttributes( { 'rpf-limit': val } ) } |
| 136 |
help={ __( 'Default limit for this repeater group. Leave 0 for unlimited.', 'profile-builder' ) } |
| 137 |
/> |
| 138 |
<TextareaControl |
| 139 |
label={ __( 'Limit Reached Message', 'profile-builder' ) } |
| 140 |
value={ limitReachedMessage } |
| 141 |
placeholder={ __( LIMIT_MESSAGE_DEFAULT, 'profile-builder' ) } |
| 142 |
onChange={ ( val ) => setAttributes( { 'rpf-limit-reached-message': val } ) } |
| 143 |
help={ __( 'Popup message shown when the limit is reached.', 'profile-builder' ) } |
| 144 |
/> |
| 145 |
<BaseControl |
| 146 |
label={ __( 'Limit per Role', 'profile-builder' ) } |
| 147 |
help={ __( 'Override the general limit for specific roles. Leave 0 for unlimited.', 'profile-builder' ) } |
| 148 |
> |
| 149 |
{ allRoles.length === 0 && ( |
| 150 |
<em>{ __( 'No roles available.', 'profile-builder' ) }</em> |
| 151 |
) } |
| 152 |
{ allRoles.map( ( role ) => ( |
| 153 |
<TextControl |
| 154 |
key={ role.value } |
| 155 |
label={ role.label } |
| 156 |
type="number" |
| 157 |
min={ 0 } |
| 158 |
step={ 1 } |
| 159 |
value={ roleLimitMap[ role.value ] ?? '' } |
| 160 |
onChange={ ( val ) => setRoleLimit( role.value, val ) } |
| 161 |
/> |
| 162 |
) ) } |
| 163 |
</BaseControl> |
| 164 |
</> |
| 165 |
) } |
| 166 |
</PanelBody> |
| 167 |
|
| 168 |
{ /* Conditional Logic / Field Visibility / admin-approval / |
| 169 |
add-on properties. This block hosts its own Fill instead of |
| 170 |
routing through BaseFieldEdit (it needs bespoke container |
| 171 |
markup for the InnerBlocks), which used to mean the |
| 172 |
cross-cutting panels were unreachable on a Repeater even |
| 173 |
though the attributes were injected and mirrored for it |
| 174 |
server-side. A top-level Repeater is |
| 175 |
never nested (the parent's allowedBlocks forbid it), so |
| 176 |
insideRepeater is constant false. */ } |
| 177 |
<CrossCuttingFieldPanels |
| 178 |
attributes={ attributes } |
| 179 |
setAttributes={ setAttributes } |
| 180 |
insideRepeater={ false } |
| 181 |
/> |
| 182 |
</FieldSettingsFill> |
| 183 |
) } |
| 184 |
|
| 185 |
<div className="wppb-fb-repeater-block__head"> |
| 186 |
<span className="wppb-fb-repeater-block__icon" aria-hidden="true"> |
| 187 |
<svg width="18" height="18" viewBox="0 0 24 24" fill="none"> |
| 188 |
<rect x="3" y="4" width="18" height="6" rx="1.5" stroke="currentColor" strokeWidth="1.7" /> |
| 189 |
<rect x="3" y="14" width="18" height="6" rx="1.5" stroke="currentColor" strokeWidth="1.7" /> |
| 190 |
</svg> |
| 191 |
</span> |
| 192 |
<span className="wppb-fb-repeater-block__title"> |
| 193 |
{ title || __( 'Repeater', 'profile-builder' ) } |
| 194 |
{ required === 'Yes' && <span className="wppb-fb-field-required">*</span> } |
| 195 |
</span> |
| 196 |
<span className="wppb-fb-repeater-block__meta"> |
| 197 |
<span className="wppb-fb-field-tag">{ __( 'Repeater', 'profile-builder' ) }</span> |
| 198 |
<span className="wppb-fb-field-num"> |
| 199 |
{ id |
| 200 |
? sprintf( /* translators: %s: field id */ __( 'Field #%s', 'profile-builder' ), id ) |
| 201 |
: __( 'Field #…', 'profile-builder' ) } |
| 202 |
</span> |
| 203 |
</span> |
| 204 |
</div> |
| 205 |
|
| 206 |
{ description && <div className="wppb-fb-repeater-block__desc">{ description }</div> } |
| 207 |
|
| 208 |
<div className="wppb-fb-repeater-block__hint"> |
| 209 |
{ __( 'Sub-fields are scoped to this Repeater — add new fields using the inserter inside this box. They can\'t be dragged in or out.', 'profile-builder' ) } |
| 210 |
</div> |
| 211 |
|
| 212 |
<div className="wppb-fb-repeater-block__body"> |
| 213 |
<InnerBlocks |
| 214 |
allowedBlocks={ ALLOWED_BLOCKS } |
| 215 |
template={ [ [ 'profile-builder/field-input' ] ] } |
| 216 |
renderAppender={ () => null } |
| 217 |
/> |
| 218 |
{ /* Same mini inserter as everywhere else. Its "Existing Fields" |
| 219 |
tab is hidden automatically because the destination is inside |
| 220 |
a Repeater — sub-fields live in the per-Repeater option, not |
| 221 |
wppb_manage_fields, so an existing top-level field can't be |
| 222 |
reused as one. See lib/miniInserterTarget.js. */ } |
| 223 |
<AddFieldButton rootClientId={ clientId } label={ __( 'Add sub-field', 'profile-builder' ) } /> |
| 224 |
</div> |
| 225 |
</div> |
| 226 |
); |
| 227 |
} |
| 228 |
|