Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
a72a541
PROD-10332 - Fix Access Control type and provider selections not savi…
jitendrabanjara1991 Aug 21, 2026
e1ed957
PROD-10332 - Refetch stale access control options on mount after cach…
jitendrabanjara1991 Aug 24, 2026
79a407c
PROD-10332 - Guard debounced save responses against out-of-order appl…
jitendrabanjara1991 Aug 24, 2026
0e95fc0
PROD-10332 - Address review: sub-type heal, single-flight saves, fetc…
jitendrabanjara1991 Aug 24, 2026
5791ccb
PROD-10332 - Make the single-flight save channel local to each feature
jitendrabanjara1991 Aug 24, 2026
342c780
PROD-10332 - Skip the success toast for superseded save responses
jitendrabanjara1991 Aug 24, 2026
0dc771c
PROD-10332 - Do not resurrect a cleared rule from stale cached enrich…
jitendrabanjara1991 Aug 24, 2026
1fe3fdb
PROD-10332 - Reconcile the feature cache even for superseded save res…
jitendrabanjara1991 Aug 24, 2026
b976e31
PROD-10332 - Detect explicitly cleared rules by value shape in initia…
jitendrabanjara1991 Aug 24, 2026
d07596d
PROD-10332 - Skip screen state for responses from a navigated-away fe…
jitendrabanjara1991 Aug 24, 2026
63d9db7
PROD-10332 - Persist per-feature save channels across feature re-entry
jitendrabanjara1991 Aug 24, 2026
ffb96a6
PROD-10332 - Scope the save sequence guard per feature channel
jitendrabanjara1991 Aug 24, 2026
f83d292
PROD-10332 - Reconcile the reactions cache regardless of supersession
jitendrabanjara1991 Aug 24, 2026
7765304
PROD-10332 - Address review: spinner, sub-type restore, cleared optio…
jitendrabanjara1991 Aug 25, 2026
2106f29
PROD-10332 - Preserve real item IDs when a reactions refetch is super…
jitendrabanjara1991 Aug 25, 2026
871fe53
PROD-10332 - Scope the items-refetch ordering state to the save channel
jitendrabanjara1991 Aug 25, 2026
c47bacc
PROD-10332 - Harden Settings 2.0 save pipeline against remount and re…
jitendrabanjara1991 Aug 25, 2026
88ecfcd
PROD-10332 - Name the feature in cross-feature save error toasts
jitendrabanjara1991 Aug 25, 2026
3f3a613
PROD-10332 - Harden channel map keys and dead-mount refetch event gating
jitendrabanjara1991 Aug 25, 2026
7209d13
PROD-10332 - Land superseded reactions items-refetch IDs on the live …
jitendrabanjara1991 Aug 25, 2026
074cf69
PROD-10332 - Preserve mid-debounce edits on refetch and prefer saved …
jitendrabanjara1991 Aug 25, 2026
3d1877d
PROD-10332 - Narrow-merge the reactions items-refetch instead of full…
jitendrabanjara1991 Aug 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1 +1 @@
<?php return array('dependencies' => array('bb-admin-common', 'react', 'react-dom', 'wp-components', 'wp-date', 'wp-element', 'wp-hooks', 'wp-html-entities', 'wp-i18n'), 'version' => '538e6aaf3cdf76fac85d');
<?php return array('dependencies' => array('bb-admin-common', 'react', 'react-dom', 'wp-components', 'wp-date', 'wp-element', 'wp-hooks', 'wp-html-entities', 'wp-i18n'), 'version' => '55e43dbe7c0940d61d87');
11 changes: 6 additions & 5 deletions src/bp-core/admin/bb-settings/settings/build/index.js

Large diffs are not rendered by default.

232 changes: 218 additions & 14 deletions src/js/admin/settings/components/access-control/AccessControlField.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
* @since BuddyBoss [BBVERSION]
*/

import { useState, useRef, RawHTML } from '@wordpress/element';
import { useState, useRef, useEffect, RawHTML } from '@wordpress/element';
import { CheckboxControl, SelectControl, ToggleControl, Spinner } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { decodeEntities } from '@wordpress/html-entities';
Expand Down Expand Up @@ -45,13 +45,17 @@ function initPerOptionSettings( selectedOpts, serverPerOption, savedValue ) {
var subKey = 'access-control-' + optKey + '-options';
var subData = null;

// Try server-provided per_option_settings first.
if ( serverPerOption && serverPerOption[ optKey ] ) {
subData = serverPerOption[ optKey ];
}
// Fallback to saved value sub-keys.
else if ( savedValue && savedValue[ subKey ] ) {
// Prefer the actually-saved value over the server enrichment. The
// enrichment (per_option_settings) is computed server-side and travels
// in the feature cache, so after a rapid change it can be stale for
// this option, whereas the saved value is exactly what the user last
// persisted. Same "trust saved value over enrichment" rule this
// component applies to type/sub-type/options; falling back to the
// enrichment only when the saved value has no sub-key for this option.
if ( savedValue && undefined !== savedValue[ subKey ] ) {
subData = savedValue[ subKey ];
} else if ( serverPerOption && serverPerOption[ optKey ] ) {
subData = serverPerOption[ optKey ];
}

if ( subData && Array.isArray( subData ) && subData.indexOf( 'all' ) !== -1 ) {
Expand Down Expand Up @@ -80,21 +84,80 @@ export function AccessControlField( { field, value, onChange } ) {
var types = wp.hooks.applyFilters( 'bb.accessControl.types', data.types || [], field );
var isThreaded = !! field.threaded;

/**
* Whether a type config is a grouped type (has selectable sub-types).
* Single source of truth for the mount heal AND both change handlers —
* Pro emits `sub_types` as an empty PHP array (JSON `[]`, truthy in JS)
* for non-grouped cases, so a bare truthiness check misclassifies.
*
* @since BuddyBoss [BBVERSION]
*
* @param {Object} cfg Type config from the types array.
* @return {boolean} True when the type has selectable sub-types.
*/
var isGroupedTypeConfig = function( cfg ) {
return !! ( cfg && cfg.sub_types && cfg.sub_types.items && cfg.sub_types.items.length > 0 );
};

// An object value without a type means the rule was explicitly cleared
// through this UI (buildValue always emits an object, and the save echo
// caches it as such). A non-object value ('' — the never-saved default)
// falls back to the enrichment, which is computed from the same stored
// option and is therefore consistent on fresh data.
var isClearedValue = !! ( value && 'object' === typeof value && ! value[ 'access-control-type' ] );

// State.
var [ selectedType, setSelectedType ] = useState( value?.[ 'access-control-type' ] || data.current_type || '' );
var [ selectedType, setSelectedType ] = useState( function() {
if ( value && value[ 'access-control-type' ] ) {
return value[ 'access-control-type' ];
}
// Explicitly cleared — render the placeholder rather than resurrecting
// the enrichment's type, which can be stale on cached SPA re-entry.
if ( value && 'object' === typeof value ) {
return '';
}
return data.current_type || '';
} );
var [ selectedSubType, setSelectedSubType ] = useState( function() {
// Determine initial sub-type from saved value using the sub-type key from PHP.
// Resolve the sub-type key for the SAVED type from the types config —
// the same trusted source buildValue() uses. The enrichment's
// current_sub_type_key exists only when a sub-type was already stored
// at panel-fetch time, so after the first-ever provider save (or a
// grouped-type switch) a stale cached enrichment carries no key — or
// another grouped type's key — and the value lookup would miss the
// saved provider, then the next save would silently erase it.
var savedType = value && value[ 'access-control-type' ];
if ( savedType ) {
for ( var i = 0; i < types.length; i++ ) {
if ( types[ i ].value === savedType ) {
var cfgKey = types[ i ].sub_types && types[ i ].sub_types.key;
if ( cfgKey && value[ cfgKey ] ) {
return value[ cfgKey ];
}
break;
}
}
}
// Fallback: enrichment-provided key (fresh-data path).
if ( data.current_sub_type_key && value?.[ data.current_sub_type_key ] ) {
return value[ data.current_sub_type_key ];
}
// Mirror the type initializer: an explicitly cleared (object) value
// must not resurrect the (possibly stale cached) enrichment sub-type.
if ( value && 'object' === typeof value ) {
return '';
}
return data.current_sub_type || '';
} );
var [ options, setOptions ] = useState( data.options || [] );
// A cleared rule must not render the stale enrichment's options list —
// the toggle list renders purely on options.length, so stale options
// under a placeholder type would invite toggling a dead rule into storage.
var [ options, setOptions ] = useState( isClearedValue ? [] : ( data.options || [] ) );
// Recipient list for threaded "Specific" checkboxes: the FULL role set
// (includes administrators + the sender's own role), unlike `options` which
// is the admin-excluded sender list. Falls back to `options` when the server
// provides no separate recipient list (legacy parity — see renderThreadedCheckboxes).
var [ recipientOptions, setRecipientOptions ] = useState( data.recipient_options || data.options || [] );
var [ recipientOptions, setRecipientOptions ] = useState( isClearedValue ? [] : ( data.recipient_options || data.options || [] ) );
var [ selectedOptions, setSelectedOptions ] = useState( value?.[ 'access-control-options' ] || [] );
var [ loading, setLoading ] = useState( false );
var [ fetchError, setFetchError ] = useState( '' );
Expand Down Expand Up @@ -123,6 +186,96 @@ export function AccessControlField( { field, value, onChange } ) {
return null;
};

// Self-heal a stale options list on mount. `data.options` comes from the
// server enrichment captured when the panel payload was fetched (and is
// served cache-first on SPA re-entry). Rapid selection changes fire
// multiple debounced saves whose responses can update that cache out of
// order, leaving enrichment computed for a different type than the saved
// value (e.g. type shows "Profile Type" but the options list still holds
// membership plans). When the enrichment's current type disagrees with
// the saved value's type, refetch the options for the saved selection.
useEffect( function() {
// The enrichment options are computed from type + sub-type, so a
// provider-only switch (same type, different sub-type) also leaves
// stale options in the cached payload — compare both.
if (
! selectedType ||
(
( data.current_type || '' ) === selectedType &&
( data.current_sub_type || '' ) === ( selectedSubType || '' )
)
) {
return undefined;
}

var typeConfig = getSelectedTypeConfig();
var action = 'get_access_control_level_options';
var fetchValue = selectedType;

if ( isGroupedTypeConfig( typeConfig ) ) {
if ( ! selectedSubType || ! typeConfig.sub_types.action ) {
setOptions( [] );
setRecipientOptions( [] );
return undefined;
}
action = typeConfig.sub_types.action;
fetchValue = selectedSubType;
}

var controller = new AbortController();
abortRef.current = controller;
setLoading( true );
// The displayed options belong to the enrichment's (different) type; if
// this refetch fails they must not stay rendered and toggleable under
// the saved selection — a toggle would persist another type's option
// keys into this rule. Clear them so the error state shows alone.
setOptions( [] );
setRecipientOptions( [] );

ajaxFetch( action, {
value: fetchValue,
key: field.name,
format: 'json',
}, { signal: controller.signal } ).then( function( response ) {
var newOptions = response?.data?.options || [];
// Match the handlers' filter signatures: 3 args for direct types
// (handleTypeChange), 4 for grouped types (handleSubTypeChange).
if ( selectedSubType ) {
newOptions = wp.hooks.applyFilters( 'bb.accessControl.options', newOptions, field, selectedType, selectedSubType );
} else {
newOptions = wp.hooks.applyFilters( 'bb.accessControl.options', newOptions, field, selectedType );
}
setOptions( newOptions );
setRecipientOptions( response?.data?.recipient_options || response?.data?.options || [] );
setLoading( false );
} ).catch( function( error ) {
if ( error && 'AbortError' === error.name ) {
return;
}
setLoading( false );
setFetchError( __( 'Failed to load options. Please try again.', 'buddyboss' ) );
} );

return function() {
controller.abort();
};
// Mount-only heal: the stale-enrichment condition can only exist at mount.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [] );

// Abort whatever options fetch is in flight when the field unmounts. The
// heal effect's cleanup only covers its own controller; fetches started by
// the change handlers live solely in abortRef and would otherwise run to
// completion against an unmounted instance (harmless no-op setState in
// React 18, but a wasted request worth cancelling).
useEffect( function() {
return function() {
if ( abortRef.current ) {
abortRef.current.abort();
}
};
}, [] );

/**
* Build the saved value object including sub-type key and per-option sub-keys.
*
Expand Down Expand Up @@ -183,6 +336,15 @@ export function AccessControlField( { field, value, onChange } ) {

// Reset to placeholder — save to clear the setting.
if ( ! newType ) {
// Kill any in-flight options fetch (mount heal or a prior change)
// so a late response can't repaint the old type's options. The
// aborted fetch's catch early-returns on AbortError, so clear the
// loading/error state here or the spinner would stick forever.
if ( abortRef.current ) {
abortRef.current.abort();
}
setLoading( false );
setFetchError( '' );
setOptions( [] );
setRecipientOptions( [] );
onChange( {
Expand All @@ -202,15 +364,35 @@ export function AccessControlField( { field, value, onChange } ) {
}

// If this type has sub-types, don't fetch options yet — wait for sub-type selection.
if ( typeConfig && typeConfig.sub_types && typeConfig.sub_types.items && typeConfig.sub_types.items.length > 0 ) {
if ( isGroupedTypeConfig( typeConfig ) ) {
// Kill any in-flight options fetch so a late response can't
// repaint the previous type's options under the grouped type,
// and clear loading/error state the aborted catch won't touch.
if ( abortRef.current ) {
abortRef.current.abort();
}
setLoading( false );
setFetchError( '' );
setOptions( [] );
setRecipientOptions( [] );

// Persist the type switch so the previous rule stops enforcing.
onChange( buildValue( newType, '', [], {} ) );
return;
}

// Persist the type switch so the previous rule stops enforcing.
onChange( buildValue( newType, '', [], {} ) );

// Direct type — fetch options via AJAX.
setLoading( true );
setFetchError( '' );
// Clear the previous type's lists before fetching: if this fetch fails,
// the toggle list must not keep rendering the old type's options under
// the new selection — toggling one would save another type's option
// keys into this rule.
setOptions( [] );
setRecipientOptions( [] );

// Cancel any in-flight request before starting a new one.
if ( abortRef.current ) {
Expand Down Expand Up @@ -250,14 +432,36 @@ export function AccessControlField( { field, value, onChange } ) {

var typeConfig = getSelectedTypeConfig();

if ( ! newSubType || ! typeConfig || ! typeConfig.sub_types ) {
if ( ! newSubType || ! isGroupedTypeConfig( typeConfig ) ) {
// Kill any in-flight options fetch so a late response can't
// repaint the previous provider's options under the placeholder,
// and clear loading/error state the aborted catch won't touch.
if ( abortRef.current ) {
abortRef.current.abort();
}
setLoading( false );
setFetchError( '' );
setOptions( [] );
setRecipientOptions( [] );

// Persist the cleared sub-type so the previous rule stops enforcing.
onChange( buildValue( selectedType, '', [], {} ) );
return;
}

// Persist the sub-type switch immediately — without this, changing the
// provider alone never saves and the previous provider's rule keeps
// enforcing silently until an option is toggled.
onChange( buildValue( selectedType, newSubType, [], {} ) );

setLoading( true );
setFetchError( '' );
// Clear the previous provider's lists before fetching: if this fetch
// fails, the toggle list must not keep rendering the old provider's
// options under the new selection — toggling one would save another
// provider's option keys into this rule.
setOptions( [] );
setRecipientOptions( [] );

// Cancel any in-flight request before starting a new one.
if ( abortRef.current ) {
Expand Down Expand Up @@ -356,7 +560,7 @@ export function AccessControlField( { field, value, onChange } ) {

// Get current type config for rendering sub-type dropdown.
var currentTypeConfig = getSelectedTypeConfig();
var hasSubTypes = currentTypeConfig && currentTypeConfig.sub_types && currentTypeConfig.sub_types.items && currentTypeConfig.sub_types.items.length > 0;
var hasSubTypes = isGroupedTypeConfig( currentTypeConfig );
var showNoOptions = ! loading && selectedType && options.length === 0;

// For grouped types, only show "no options" if a sub-type is selected.
Expand Down
Loading
Loading