From 87e2a3b1fe1c22b011b6d3792e2a6255d8e4d81a Mon Sep 17 00:00:00 2001 From: dkoo Date: Tue, 7 Apr 2026 16:32:03 -0600 Subject: [PATCH 01/34] feat(content-gate): add Block_Visibility class skeleton Co-Authored-By: Claude Sonnet 4.6 --- .../content-gate/class-block-visibility.php | 57 +++++++++++++++++++ includes/content-gate/class-content-gate.php | 1 + .../content-gate/class-block-visibility.php | 31 ++++++++++ 3 files changed, 89 insertions(+) create mode 100644 includes/content-gate/class-block-visibility.php create mode 100644 tests/unit-tests/content-gate/class-block-visibility.php diff --git a/includes/content-gate/class-block-visibility.php b/includes/content-gate/class-block-visibility.php new file mode 100644 index 0000000000..b51554a1cd --- /dev/null +++ b/includes/content-gate/class-block-visibility.php @@ -0,0 +1,57 @@ +assertTrue( class_exists( 'Newspack\Block_Visibility' ) ); + } + + /** + * Test that the render_block filter is registered. + */ + public function test_render_block_filter_registered() { + $this->assertNotFalse( + has_filter( 'render_block', [ 'Newspack\Block_Visibility', 'filter_render_block' ] ) + ); + } +} From 4b2c0f2aeb532bbc33e85edeb06a56744aaa4971 Mon Sep 17 00:00:00 2001 From: dkoo Date: Tue, 7 Apr 2026 16:36:21 -0600 Subject: [PATCH 02/34] test(content-gate): complete Block_Visibility hook coverage Co-Authored-By: Claude Sonnet 4.6 --- .../content-gate/class-block-visibility.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/unit-tests/content-gate/class-block-visibility.php b/tests/unit-tests/content-gate/class-block-visibility.php index c6c41e5903..47b3dc98f7 100644 --- a/tests/unit-tests/content-gate/class-block-visibility.php +++ b/tests/unit-tests/content-gate/class-block-visibility.php @@ -28,4 +28,22 @@ public function test_render_block_filter_registered() { has_filter( 'render_block', [ 'Newspack\Block_Visibility', 'filter_render_block' ] ) ); } + + /** + * Test that the enqueue_block_editor_assets action is registered. + */ + public function test_enqueue_block_editor_assets_action_registered() { + $this->assertNotFalse( + has_action( 'enqueue_block_editor_assets', [ 'Newspack\Block_Visibility', 'enqueue_block_editor_assets' ] ) + ); + } + + /** + * Test that the register_block_type_args filter is registered. + */ + public function test_register_block_type_args_filter_registered() { + $this->assertNotFalse( + has_filter( 'register_block_type_args', [ 'Newspack\Block_Visibility', 'register_block_type_args' ] ) + ); + } } From bce23064d8b2ebc87e02c388c9d94768fc7488df Mon Sep 17 00:00:00 2001 From: dkoo Date: Tue, 7 Apr 2026 16:39:58 -0600 Subject: [PATCH 03/34] feat(content-gate): implement render_block fast path Co-Authored-By: Claude Sonnet 4.6 --- .../content-gate/class-block-visibility.php | 20 +++++++ .../content-gate/class-block-visibility.php | 58 +++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/includes/content-gate/class-block-visibility.php b/includes/content-gate/class-block-visibility.php index b51554a1cd..072cad7979 100644 --- a/includes/content-gate/class-block-visibility.php +++ b/includes/content-gate/class-block-visibility.php @@ -33,6 +33,26 @@ public static function init() { * @return string */ public static function filter_render_block( $block_content, $block ) { + $target_blocks = [ 'core/group', 'core/stack', 'core/row' ]; + if ( ! in_array( $block['blockName'] ?? '', $target_blocks, true ) ) { + return $block_content; + } + + if ( is_admin() ) { + return $block_content; + } + + $rules = $block['attrs']['newspackAccessControlRules'] ?? []; + + $has_registration = ! empty( $rules['registration']['active'] ); + $has_access_rules = ! empty( $rules['custom_access']['active'] ) + && ! empty( $rules['custom_access']['access_rules'] ); + + if ( ! $has_registration && ! $has_access_rules ) { + return $block_content; + } + + // Full evaluation handled in Task 5. return $block_content; } diff --git a/tests/unit-tests/content-gate/class-block-visibility.php b/tests/unit-tests/content-gate/class-block-visibility.php index 47b3dc98f7..86d362ca09 100644 --- a/tests/unit-tests/content-gate/class-block-visibility.php +++ b/tests/unit-tests/content-gate/class-block-visibility.php @@ -46,4 +46,62 @@ public function test_register_block_type_args_filter_registered() { has_filter( 'register_block_type_args', [ 'Newspack\Block_Visibility', 'register_block_type_args' ] ) ); } + + /** + * Helper to build a mock block array. + * + * @param string $name Block name. + * @param array $attrs Block attributes. + * @return array + */ + private function make_block( $name, $attrs = [] ) { + return [ + 'blockName' => $name, + 'attrs' => $attrs, + 'innerHTML' => '
content
', + ]; + } + + /** + * Test that non-target blocks pass through unchanged. + */ + public function test_non_target_block_passes_through() { + $result = Block_Visibility::filter_render_block( '

hello

', $this->make_block( 'core/paragraph' ) ); + $this->assertSame( '

hello

', $result ); + } + + /** + * Test that a target block with no attrs passes through unchanged. + */ + public function test_target_block_with_no_rules_passes_through() { + $result = Block_Visibility::filter_render_block( '
hi
', $this->make_block( 'core/group', [] ) ); + $this->assertSame( '
hi
', $result ); + } + + /** + * Test that a target block with an empty rules object passes through unchanged. + */ + public function test_target_block_with_empty_rules_object_passes_through() { + $result = Block_Visibility::filter_render_block( + '
hi
', + $this->make_block( 'core/group', [ 'newspackAccessControlRules' => [] ] ) + ); + $this->assertSame( '
hi
', $result ); + } + + /** + * Test that a target block with only inactive rules passes through unchanged. + */ + public function test_target_block_with_inactive_rules_passes_through() { + $result = Block_Visibility::filter_render_block( + '
hi
', + $this->make_block( 'core/group', [ + 'newspackAccessControlRules' => [ + 'registration' => [ 'active' => false ], + 'custom_access' => [ 'active' => false, 'access_rules' => [] ], + ], + ] ) + ); + $this->assertSame( '
hi
', $result ); + } } From e3920bbac91a796befe598e4a2c7a6e8f990f74c Mon Sep 17 00:00:00 2001 From: dkoo Date: Tue, 7 Apr 2026 16:45:22 -0600 Subject: [PATCH 04/34] test(content-gate): add is_admin fast path coverage Co-Authored-By: Claude Sonnet 4.6 --- .../content-gate/class-block-visibility.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/unit-tests/content-gate/class-block-visibility.php b/tests/unit-tests/content-gate/class-block-visibility.php index 86d362ca09..80391c3a11 100644 --- a/tests/unit-tests/content-gate/class-block-visibility.php +++ b/tests/unit-tests/content-gate/class-block-visibility.php @@ -104,4 +104,19 @@ public function test_target_block_with_inactive_rules_passes_through() { ); $this->assertSame( '
hi
', $result ); } + + /** + * Test that a target block with active rules passes through unchanged when is_admin() is true. + */ + public function test_target_block_with_rules_passes_through_in_admin() { + set_current_screen( 'dashboard' ); + $block = $this->make_block( 'core/group', [ + 'newspackAccessControlRules' => [ + 'registration' => [ 'active' => true ], + ], + ] ); + $result = Block_Visibility::filter_render_block( '
admin view
', $block ); + $this->assertSame( '
admin view
', $result ); + unset( $GLOBALS['current_screen'] ); + } } From 4479ff38d008455588a3e9beb7fe33542365f64f Mon Sep 17 00:00:00 2001 From: dkoo Date: Tue, 7 Apr 2026 16:48:12 -0600 Subject: [PATCH 05/34] feat(content-gate): implement registration rule evaluation Co-Authored-By: Claude Sonnet 4.6 --- .../content-gate/class-block-visibility.php | 72 ++++++++++++++ .../content-gate/class-block-visibility.php | 99 ++++++++++++++++--- 2 files changed, 160 insertions(+), 11 deletions(-) diff --git a/includes/content-gate/class-block-visibility.php b/includes/content-gate/class-block-visibility.php index 072cad7979..a755158063 100644 --- a/includes/content-gate/class-block-visibility.php +++ b/includes/content-gate/class-block-visibility.php @@ -73,5 +73,77 @@ public static function register_block_type_args( $args, $block_type ) { public static function enqueue_block_editor_assets() { // No-op until implemented. } + + /** + * Per-request cache: keyed by "{user_id}:{md5(rules)}". + * + * @var bool[] + */ + private static $rules_match_cache = []; + + /** + * Reset the per-request cache. Used in unit tests only. + */ + public static function reset_cache_for_tests() { + self::$rules_match_cache = []; + } + + /** + * Public wrapper for tests. Calls evaluate_rules_for_user(). + * + * @param array $rules Rules array. + * @param int $user_id User ID. + * @return bool + */ + public static function evaluate_rules_for_user_public( $rules, $user_id ) { + return self::evaluate_rules_for_user( $rules, $user_id ); + } + + /** + * Evaluate whether a user matches the block's access rules. + * + * @param array $rules Parsed newspackAccessControlRules attribute. + * @param int $user_id User ID (0 for logged-out). + * @return bool True if user matches (should be treated as "matching reader"). + */ + private static function evaluate_rules_for_user( $rules, $user_id ) { + $cache_key = $user_id . ':' . md5( wp_json_encode( $rules ) ); + if ( isset( self::$rules_match_cache[ $cache_key ] ) ) { + return self::$rules_match_cache[ $cache_key ]; + } + + $result = self::compute_rules_match( $rules, $user_id ); + self::$rules_match_cache[ $cache_key ] = $result; + return $result; + } + + /** + * Compute whether a user matches the block's access rules (uncached). + * + * @param array $rules Parsed newspackAccessControlRules attribute. + * @param int $user_id User ID (0 for logged-out). + * @return bool + */ + private static function compute_rules_match( $rules, $user_id ) { + $registration = $rules['registration'] ?? []; + $custom_access = $rules['custom_access'] ?? []; + + $registration_passes = true; + if ( ! empty( $registration['active'] ) ) { + if ( ! $user_id ) { + $registration_passes = false; + } elseif ( ! empty( $registration['require_verification'] ) ) { + $registration_passes = (bool) get_user_meta( $user_id, Reader_Activation::EMAIL_VERIFIED, true ); + } + } + + $access_passes = true; + if ( ! empty( $custom_access['active'] ) && ! empty( $custom_access['access_rules'] ) ) { + $access_passes = Access_Rules::evaluate_rules( $custom_access['access_rules'], $user_id ); + } + + // AND logic: both must pass when both are configured. + return $registration_passes && $access_passes; + } } Block_Visibility::init(); diff --git a/tests/unit-tests/content-gate/class-block-visibility.php b/tests/unit-tests/content-gate/class-block-visibility.php index 80391c3a11..8513008e84 100644 --- a/tests/unit-tests/content-gate/class-block-visibility.php +++ b/tests/unit-tests/content-gate/class-block-visibility.php @@ -13,6 +13,30 @@ */ class Newspack_Test_Block_Visibility extends WP_UnitTestCase { + /** + * Test user ID. + * + * @var int + */ + private $test_user_id; + + /** + * Set up test environment. + */ + public function set_up() { + parent::set_up(); + $this->test_user_id = $this->factory->user->create( [ 'role' => 'subscriber' ] ); + } + + /** + * Tear down test environment. + */ + public function tear_down() { + Block_Visibility::reset_cache_for_tests(); + wp_set_current_user( 0 ); + parent::tear_down(); + } + /** * Test that the Block_Visibility class exists. */ @@ -95,12 +119,18 @@ public function test_target_block_with_empty_rules_object_passes_through() { public function test_target_block_with_inactive_rules_passes_through() { $result = Block_Visibility::filter_render_block( '
hi
', - $this->make_block( 'core/group', [ - 'newspackAccessControlRules' => [ - 'registration' => [ 'active' => false ], - 'custom_access' => [ 'active' => false, 'access_rules' => [] ], - ], - ] ) + $this->make_block( + 'core/group', + [ + 'newspackAccessControlRules' => [ + 'registration' => [ 'active' => false ], + 'custom_access' => [ + 'active' => false, + 'access_rules' => [], + ], + ], + ] + ) ); $this->assertSame( '
hi
', $result ); } @@ -110,13 +140,60 @@ public function test_target_block_with_inactive_rules_passes_through() { */ public function test_target_block_with_rules_passes_through_in_admin() { set_current_screen( 'dashboard' ); - $block = $this->make_block( 'core/group', [ - 'newspackAccessControlRules' => [ - 'registration' => [ 'active' => true ], - ], - ] ); + $block = $this->make_block( + 'core/group', + [ + 'newspackAccessControlRules' => [ + 'registration' => [ 'active' => true ], + ], + ] + ); $result = Block_Visibility::filter_render_block( '
admin view
', $block ); $this->assertSame( '
admin view
', $result ); unset( $GLOBALS['current_screen'] ); } + + /** + * Registration: logged-out user does not match. + */ + public function test_registration_logged_out_does_not_match() { + wp_set_current_user( 0 ); + $rules = [ 'registration' => [ 'active' => true ] ]; + $this->assertFalse( Block_Visibility::evaluate_rules_for_user_public( $rules, 0 ) ); + } + + /** + * Registration: logged-in user matches. + */ + public function test_registration_logged_in_matches() { + $rules = [ 'registration' => [ 'active' => true ] ]; + $this->assertTrue( Block_Visibility::evaluate_rules_for_user_public( $rules, $this->test_user_id ) ); + } + + /** + * Registration + require_verification: unverified user does not match. + */ + public function test_registration_unverified_does_not_match() { + $rules = [ + 'registration' => [ + 'active' => true, + 'require_verification' => true, + ], + ]; + $this->assertFalse( Block_Visibility::evaluate_rules_for_user_public( $rules, $this->test_user_id ) ); + } + + /** + * Registration + require_verification: verified user matches. + */ + public function test_registration_verified_matches() { + update_user_meta( $this->test_user_id, \Newspack\Reader_Activation::EMAIL_VERIFIED, true ); + $rules = [ + 'registration' => [ + 'active' => true, + 'require_verification' => true, + ], + ]; + $this->assertTrue( Block_Visibility::evaluate_rules_for_user_public( $rules, $this->test_user_id ) ); + } } From a0663813edd03f6617f6caf88c53143f129812d5 Mon Sep 17 00:00:00 2001 From: dkoo Date: Tue, 7 Apr 2026 16:52:49 -0600 Subject: [PATCH 06/34] style(content-gate): remove trailing whitespace in Block_Visibility tests --- tests/unit-tests/content-gate/class-block-visibility.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit-tests/content-gate/class-block-visibility.php b/tests/unit-tests/content-gate/class-block-visibility.php index 8513008e84..b033625cb1 100644 --- a/tests/unit-tests/content-gate/class-block-visibility.php +++ b/tests/unit-tests/content-gate/class-block-visibility.php @@ -129,7 +129,7 @@ public function test_target_block_with_inactive_rules_passes_through() { 'access_rules' => [], ], ], - ] + ] ) ); $this->assertSame( '
hi
', $result ); @@ -146,7 +146,7 @@ public function test_target_block_with_rules_passes_through_in_admin() { 'newspackAccessControlRules' => [ 'registration' => [ 'active' => true ], ], - ] + ] ); $result = Block_Visibility::filter_render_block( '
admin view
', $block ); $this->assertSame( '
admin view
', $result ); From c572a67a45505df461310ad225366971c5bf3466 Mon Sep 17 00:00:00 2001 From: dkoo Date: Wed, 8 Apr 2026 09:12:19 -0600 Subject: [PATCH 07/34] test(content-gate): add access rule evaluation and caching tests --- .../content-gate/class-block-visibility.php | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/tests/unit-tests/content-gate/class-block-visibility.php b/tests/unit-tests/content-gate/class-block-visibility.php index b033625cb1..dc81f93f1c 100644 --- a/tests/unit-tests/content-gate/class-block-visibility.php +++ b/tests/unit-tests/content-gate/class-block-visibility.php @@ -26,6 +26,17 @@ class Newspack_Test_Block_Visibility extends WP_UnitTestCase { public function set_up() { parent::set_up(); $this->test_user_id = $this->factory->user->create( [ 'role' => 'subscriber' ] ); + + // Register a simple test rule: passes only for our test user. + \Newspack\Access_Rules::register_rule( + [ + 'id' => 'test_rule', + 'name' => 'Test Rule', + 'callback' => function( $user_id, $value ) { + return intval( $user_id ) === intval( $value ); + }, + ] + ); } /** @@ -196,4 +207,77 @@ public function test_registration_verified_matches() { ]; $this->assertTrue( Block_Visibility::evaluate_rules_for_user_public( $rules, $this->test_user_id ) ); } + + /** + * Custom access rule: matching user passes. + */ + public function test_access_rule_matching_user_passes() { + $rules = [ + 'custom_access' => [ + 'active' => true, + 'access_rules' => [ [ [ 'slug' => 'test_rule', 'value' => $this->test_user_id ] ] ], + ], + ]; + $this->assertTrue( Block_Visibility::evaluate_rules_for_user_public( $rules, $this->test_user_id ) ); + } + + /** + * Custom access rule: non-matching user fails. + */ + public function test_access_rule_non_matching_user_fails() { + $other_user = $this->factory->user->create( [ 'role' => 'subscriber' ] ); + $rules = [ + 'custom_access' => [ + 'active' => true, + 'access_rules' => [ [ [ 'slug' => 'test_rule', 'value' => $this->test_user_id ] ] ], + ], + ]; + $this->assertFalse( Block_Visibility::evaluate_rules_for_user_public( $rules, $other_user ) ); + } + + /** + * AND logic: registration + access rules — both must pass. + */ + public function test_and_logic_both_must_pass() { + $rules = [ + 'registration' => [ 'active' => true ], + 'custom_access' => [ + 'active' => true, + 'access_rules' => [ [ [ 'slug' => 'test_rule', 'value' => $this->test_user_id ] ] ], + ], + ]; + // Logged-in user who matches the access rule: passes. + $this->assertTrue( Block_Visibility::evaluate_rules_for_user_public( $rules, $this->test_user_id ) ); + + // Logged-out user: fails (registration not met). + Block_Visibility::reset_cache_for_tests(); + $this->assertFalse( Block_Visibility::evaluate_rules_for_user_public( $rules, 0 ) ); + } + + /** + * Caching: second call returns cached result without re-evaluation. + */ + public function test_result_is_cached() { + $call_count = 0; + \Newspack\Access_Rules::register_rule( + [ + 'id' => 'counting_rule', + 'name' => 'Counting Rule', + 'callback' => function( $user_id, $value ) use ( &$call_count ) { + $call_count++; + return true; + }, + ] + ); + $rules = [ + 'custom_access' => [ + 'active' => true, + 'access_rules' => [ [ [ 'slug' => 'counting_rule', 'value' => null ] ] ], + ], + ]; + Block_Visibility::evaluate_rules_for_user_public( $rules, $this->test_user_id ); + Block_Visibility::evaluate_rules_for_user_public( $rules, $this->test_user_id ); + // Callback fired only once despite two calls with identical rules + user. + $this->assertSame( 1, $call_count ); + } } From fe6fbe5279dc3ef630bf85304cd5abeb4f55c8a3 Mon Sep 17 00:00:00 2001 From: dkoo Date: Wed, 8 Apr 2026 09:15:58 -0600 Subject: [PATCH 08/34] feat(content-gate): implement block visibility render filter Co-Authored-By: Claude Sonnet 4.6 --- .../content-gate/class-block-visibility.php | 11 ++- .../content-gate/class-block-visibility.php | 99 +++++++++++++++++++ 2 files changed, 108 insertions(+), 2 deletions(-) diff --git a/includes/content-gate/class-block-visibility.php b/includes/content-gate/class-block-visibility.php index a755158063..49b1bc70dc 100644 --- a/includes/content-gate/class-block-visibility.php +++ b/includes/content-gate/class-block-visibility.php @@ -52,8 +52,15 @@ public static function filter_render_block( $block_content, $block ) { return $block_content; } - // Full evaluation handled in Task 5. - return $block_content; + $visibility = $block['attrs']['newspackAccessControlVisibility'] ?? 'visible'; + $user_id = get_current_user_id(); + $user_matches = self::evaluate_rules_for_user( $rules, $user_id ); + + if ( 'visible' === $visibility ) { + return $user_matches ? $block_content : ''; + } + // 'hidden' + return $user_matches ? '' : $block_content; } /** diff --git a/tests/unit-tests/content-gate/class-block-visibility.php b/tests/unit-tests/content-gate/class-block-visibility.php index dc81f93f1c..a579aa7f05 100644 --- a/tests/unit-tests/content-gate/class-block-visibility.php +++ b/tests/unit-tests/content-gate/class-block-visibility.php @@ -254,6 +254,105 @@ public function test_and_logic_both_must_pass() { $this->assertFalse( Block_Visibility::evaluate_rules_for_user_public( $rules, 0 ) ); } + /** + * Helper: build a block with both control attributes. + * + * @param string $block_name Block type name. + * @param array $rules newspackAccessControlRules value. + * @param string $visibility 'visible' or 'hidden'. + * @return array + */ + private function make_block_with_rules( $block_name, $rules, $visibility = 'visible' ) { + return $this->make_block( + $block_name, + [ + 'newspackAccessControlRules' => $rules, + 'newspackAccessControlVisibility' => $visibility, + ] + ); + } + + /** + * "visible" mode: matching user sees the block. + */ + public function test_visible_mode_matching_user_sees_block() { + wp_set_current_user( $this->test_user_id ); + Block_Visibility::reset_cache_for_tests(); + $rules = [ 'registration' => [ 'active' => true ] ]; + $block = $this->make_block_with_rules( 'core/group', $rules, 'visible' ); + $result = Block_Visibility::filter_render_block( '
secret
', $block ); + $this->assertSame( '
secret
', $result ); + } + + /** + * "visible" mode: non-matching user does not see the block. + */ + public function test_visible_mode_non_matching_user_hidden() { + wp_set_current_user( 0 ); + Block_Visibility::reset_cache_for_tests(); + $rules = [ 'registration' => [ 'active' => true ] ]; + $block = $this->make_block_with_rules( 'core/group', $rules, 'visible' ); + $result = Block_Visibility::filter_render_block( '
secret
', $block ); + $this->assertSame( '', $result ); + } + + /** + * "hidden" mode: matching user does not see the block. + */ + public function test_hidden_mode_matching_user_hidden() { + wp_set_current_user( $this->test_user_id ); + Block_Visibility::reset_cache_for_tests(); + $rules = [ 'registration' => [ 'active' => true ] ]; + $block = $this->make_block_with_rules( 'core/group', $rules, 'hidden' ); + $result = Block_Visibility::filter_render_block( '
members only
', $block ); + $this->assertSame( '', $result ); + } + + /** + * "hidden" mode: non-matching user sees the block. + */ + public function test_hidden_mode_non_matching_user_sees_block() { + wp_set_current_user( 0 ); + Block_Visibility::reset_cache_for_tests(); + $rules = [ 'registration' => [ 'active' => true ] ]; + $block = $this->make_block_with_rules( 'core/group', $rules, 'hidden' ); + $result = Block_Visibility::filter_render_block( '
non-member content
', $block ); + $this->assertSame( '
non-member content
', $result ); + } + + /** + * All three target block types are evaluated. + */ + public function test_all_target_block_types_evaluated() { + wp_set_current_user( 0 ); + Block_Visibility::reset_cache_for_tests(); + $rules = [ 'registration' => [ 'active' => true ] ]; + foreach ( [ 'core/group', 'core/stack', 'core/row' ] as $block_name ) { + Block_Visibility::reset_cache_for_tests(); + $block = $this->make_block_with_rules( $block_name, $rules, 'visible' ); + $result = Block_Visibility::filter_render_block( '
x
', $block ); + $this->assertSame( '', $result, "Expected empty for $block_name" ); + } + } + + /** + * Missing visibility attribute defaults to "visible". + */ + public function test_missing_visibility_attribute_defaults_to_visible() { + wp_set_current_user( 0 ); + Block_Visibility::reset_cache_for_tests(); + $block = $this->make_block( + 'core/group', + [ + 'newspackAccessControlRules' => [ 'registration' => [ 'active' => true ] ], + // newspackAccessControlVisibility intentionally omitted + ] + ); + $result = Block_Visibility::filter_render_block( '
x
', $block ); + // logged-out user: rules don't match, so hidden under default "visible" mode + $this->assertSame( '', $result ); + } + /** * Caching: second call returns cached result without re-evaluation. */ From 2dd68d6af8e95d20b6ba815429d758d299a2654b Mon Sep 17 00:00:00 2001 From: dkoo Date: Wed, 8 Apr 2026 09:19:24 -0600 Subject: [PATCH 09/34] feat(content-gate): register block attributes server-side Co-Authored-By: Claude Sonnet 4.6 --- .../content-gate/class-block-visibility.php | 18 ++++++++++++++++++ .../content-gate/class-block-visibility.php | 9 +++++++++ 2 files changed, 27 insertions(+) diff --git a/includes/content-gate/class-block-visibility.php b/includes/content-gate/class-block-visibility.php index 49b1bc70dc..7128d0e928 100644 --- a/includes/content-gate/class-block-visibility.php +++ b/includes/content-gate/class-block-visibility.php @@ -71,6 +71,24 @@ public static function filter_render_block( $block_content, $block ) { * @return array */ public static function register_block_type_args( $args, $block_type ) { + $target_blocks = [ 'core/group', 'core/stack', 'core/row' ]; + if ( ! in_array( $block_type, $target_blocks, true ) ) { + return $args; + } + + $args['attributes'] = array_merge( + $args['attributes'] ?? [], + [ + 'newspackAccessControlVisibility' => [ + 'type' => 'string', + 'default' => 'visible', + ], + 'newspackAccessControlRules' => [ + 'type' => 'object', + 'default' => new \stdClass(), + ], + ] + ); return $args; } diff --git a/tests/unit-tests/content-gate/class-block-visibility.php b/tests/unit-tests/content-gate/class-block-visibility.php index a579aa7f05..4aef7787f4 100644 --- a/tests/unit-tests/content-gate/class-block-visibility.php +++ b/tests/unit-tests/content-gate/class-block-visibility.php @@ -353,6 +353,15 @@ public function test_missing_visibility_attribute_defaults_to_visible() { $this->assertSame( '', $result ); } + /** + * core/group block has both visibility attributes registered server-side. + */ + public function test_group_block_has_visibility_attribute_registered() { + $block_type = \WP_Block_Type_Registry::get_instance()->get_registered( 'core/group' ); + $this->assertArrayHasKey( 'newspackAccessControlVisibility', $block_type->attributes ); + $this->assertArrayHasKey( 'newspackAccessControlRules', $block_type->attributes ); + } + /** * Caching: second call returns cached result without re-evaluation. */ From ea4eb671772c5011e8d1afa2cd6ad2d4a1f6c53e Mon Sep 17 00:00:00 2001 From: dkoo Date: Wed, 8 Apr 2026 09:19:50 -0600 Subject: [PATCH 10/34] feat(content-gate): enqueue block visibility editor assets Co-Authored-By: Claude Sonnet 4.6 --- .../content-gate/class-block-visibility.php | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/includes/content-gate/class-block-visibility.php b/includes/content-gate/class-block-visibility.php index 7128d0e928..4ab37bbf96 100644 --- a/includes/content-gate/class-block-visibility.php +++ b/includes/content-gate/class-block-visibility.php @@ -96,7 +96,39 @@ public static function register_block_type_args( $args, $block_type ) { * Enqueue block editor assets. */ public static function enqueue_block_editor_assets() { - // No-op until implemented. + if ( ! current_user_can( 'edit_posts' ) ) { + return; + } + + $available_post_types = array_column( + Content_Restriction_Control::get_available_post_types(), + 'value' + ); + if ( ! in_array( get_post_type(), $available_post_types, true ) ) { + return; + } + + $asset_file = dirname( NEWSPACK_PLUGIN_FILE ) . '/dist/content-gate-block-visibility.asset.php'; + if ( ! file_exists( $asset_file ) ) { + return; + } + $asset = require $asset_file; + + wp_enqueue_script( + 'newspack-content-gate-block-visibility', + Newspack::plugin_url() . '/dist/content-gate-block-visibility.js', + $asset['dependencies'], + $asset['version'], + true + ); + + wp_localize_script( + 'newspack-content-gate-block-visibility', + 'newspackBlockVisibility', + [ + 'available_access_rules' => Access_Rules::get_access_rules(), + ] + ); } /** From 39954f54e88fd8f1a67abca8703a04291c4820fe Mon Sep 17 00:00:00 2001 From: dkoo Date: Wed, 8 Apr 2026 09:22:36 -0600 Subject: [PATCH 11/34] fix(content-gate): use edit_others_posts cap and strip callbacks from localized rules --- includes/content-gate/class-block-visibility.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/includes/content-gate/class-block-visibility.php b/includes/content-gate/class-block-visibility.php index 4ab37bbf96..51f3c80353 100644 --- a/includes/content-gate/class-block-visibility.php +++ b/includes/content-gate/class-block-visibility.php @@ -96,7 +96,7 @@ public static function register_block_type_args( $args, $block_type ) { * Enqueue block editor assets. */ public static function enqueue_block_editor_assets() { - if ( ! current_user_can( 'edit_posts' ) ) { + if ( ! current_user_can( 'edit_others_posts' ) ) { return; } @@ -126,7 +126,13 @@ public static function enqueue_block_editor_assets() { 'newspack-content-gate-block-visibility', 'newspackBlockVisibility', [ - 'available_access_rules' => Access_Rules::get_access_rules(), + 'available_access_rules' => array_map( + function( $rule ) { + unset( $rule['callback'] ); + return $rule; + }, + Access_Rules::get_access_rules() + ), ] ); } From ee8e3d06e54fab3aadcd485a7df035270d66521c Mon Sep 17 00:00:00 2001 From: dkoo Date: Wed, 8 Apr 2026 09:43:55 -0600 Subject: [PATCH 12/34] feat(content-gate): add block visibility JS entry and attribute registration --- src/content-gate/editor/block-visibility.tsx | 70 ++++++++++++++++++++ webpack.config.js | 1 + 2 files changed, 71 insertions(+) create mode 100644 src/content-gate/editor/block-visibility.tsx diff --git a/src/content-gate/editor/block-visibility.tsx b/src/content-gate/editor/block-visibility.tsx new file mode 100644 index 0000000000..58fd7e2250 --- /dev/null +++ b/src/content-gate/editor/block-visibility.tsx @@ -0,0 +1,70 @@ +/** + * WordPress dependencies + */ +import { addFilter } from '@wordpress/hooks'; +import { createHigherOrderComponent } from '@wordpress/compose'; +import { InspectorControls } from '@wordpress/block-editor'; +import { PanelBody } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; + +/** + * Target block types that receive access control attributes. + */ +const TARGET_BLOCKS = [ 'core/group', 'core/stack', 'core/row' ]; + +/** + * Register custom attributes on target block types. + */ +addFilter( + 'blocks.registerBlockType', + 'newspack-plugin/block-visibility/attributes', + ( settings: any, name: string ) => { + if ( ! TARGET_BLOCKS.includes( name ) ) { + return settings; + } + return { + ...settings, + attributes: { + ...settings.attributes, + newspackAccessControlVisibility: { + type: 'string', + default: 'visible', + }, + newspackAccessControlRules: { + type: 'object', + default: {}, + }, + }, + }; + } +); + +/** + * Inspector panel placeholder — full implementation in Task 9. + */ +const BlockVisibilityPanel = ( _props: any ) => null; + +/** + * Inject the Inspector panel into target block editors. + */ +addFilter( + 'editor.BlockEdit', + 'newspack-plugin/block-visibility/inspector', + createHigherOrderComponent( + BlockEdit => { + const WithBlockVisibilityPanel = ( props: any ) => { + if ( ! TARGET_BLOCKS.includes( props.name ) ) { + return ; + } + return ( + <> + + + + ); + }; + return WithBlockVisibilityPanel; + }, + 'withBlockVisibilityPanel' + ) +); diff --git a/webpack.config.js b/webpack.config.js index 7ec9d016da..4161fe7baa 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -67,6 +67,7 @@ const entry = { 'content-gate-editor-memberships': path.join( __dirname, 'src', 'content-gate', 'editor', 'memberships.js' ), 'content-gate-editor-metering': path.join( __dirname, 'src', 'content-gate', 'editor', 'metering-settings.js' ), 'content-gate-block-patterns': path.join( __dirname, 'src', 'content-gate', 'editor', 'block-patterns.js' ), + 'content-gate-block-visibility': path.join( __dirname, 'src', 'content-gate', 'editor', 'block-visibility.tsx' ), 'content-gate-post-settings': path.join( __dirname, 'src', 'content-gate', 'editor', 'post-settings.js' ), 'content-banner': path.join( __dirname, 'src', 'content-gate', 'content-banner.js' ), wizards: path.join( __dirname, 'src', 'wizards', 'index.tsx' ), From b2c8ea2e0ef7be3a017cf33aa8f92f376b85f5db Mon Sep 17 00:00:00 2001 From: dkoo Date: Wed, 8 Apr 2026 09:54:38 -0600 Subject: [PATCH 13/34] feat(content-gate): implement block visibility Inspector panel --- src/content-gate/editor/block-visibility.tsx | 198 ++++++++++++++++++- 1 file changed, 195 insertions(+), 3 deletions(-) diff --git a/src/content-gate/editor/block-visibility.tsx b/src/content-gate/editor/block-visibility.tsx index 58fd7e2250..e023464574 100644 --- a/src/content-gate/editor/block-visibility.tsx +++ b/src/content-gate/editor/block-visibility.tsx @@ -4,7 +4,14 @@ import { addFilter } from '@wordpress/hooks'; import { createHigherOrderComponent } from '@wordpress/compose'; import { InspectorControls } from '@wordpress/block-editor'; -import { PanelBody } from '@wordpress/components'; +import { + PanelBody, + ToggleControl, + // eslint-disable-next-line @wordpress/no-unsafe-wp-apis + __experimentalToggleGroupControl as ToggleGroupControl, + // eslint-disable-next-line @wordpress/no-unsafe-wp-apis + __experimentalToggleGroupControlOption as ToggleGroupControlOption, +} from '@wordpress/components'; import { __ } from '@wordpress/i18n'; /** @@ -40,9 +47,194 @@ addFilter( ); /** - * Inspector panel placeholder — full implementation in Task 9. + * Available access rules from localized data. */ -const BlockVisibilityPanel = ( _props: any ) => null; +const availableAccessRules: Record< string, any > = + ( window as any ).newspackBlockVisibility?.available_access_rules ?? {}; + +/** + * Whether any rules are currently active on the block. + */ +function hasActiveRules( rules: Record< string, any > ): boolean { + return !! rules?.registration?.active || !! rules?.custom_access?.active; +} + +/** Wraps ToggleGroupControl with the two standard visibility options. */ +const ToggleGroupControlCompat = ( { label, value, onChange }: any ) => ( + + + + +); + +/** + * Value control for a single access rule — stub, full implementation in Task 10. + */ +const AccessRuleValueControl = ( _props: any ) => null; + +/** One toggle + value control per available access rule. */ +const AccessRulesControls = ( { activeRules, onChange }: any ) => { + const handleToggle = ( slug: string, defaultValue: any ) => { + const has = activeRules.some( ( r: any ) => r.slug === slug ); + if ( has ) { + onChange( activeRules.filter( ( r: any ) => r.slug !== slug ) ); + } else { + onChange( [ ...activeRules, { slug, value: defaultValue } ] ); + } + }; + + const handleValueChange = ( slug: string, value: any ) => { + onChange( activeRules.map( ( r: any ) => ( r.slug === slug ? { ...r, value } : r ) ) ); + }; + + return ( + <> + { Object.entries( availableAccessRules ).map( ( [ slug, config ]: [ string, any ] ) => { + const activeRule = activeRules.find( ( r: any ) => r.slug === slug ); + return ( +
+ handleToggle( slug, config.default ) } + __nextHasNoMarginBottom + /> + { activeRule && ( + handleValueChange( slug, v ) } + /> + ) } +
+ ); + } ) } + + ); +}; + +/** Registration section: logged-in toggle + optional verification sub-toggle. */ +const RegistrationControls = ( { registration, onChange }: any ) => ( + <> + onChange( { ...registration, active } ) } + __nextHasNoMarginBottom + /> + { registration.active && ( + + onChange( { ...registration, require_verification } ) + } + __nextHasNoMarginBottom + /> + ) } + +); + +/** + * Inspector panel for block access control. + */ +const BlockVisibilityPanel = ( { attributes, setAttributes }: any ) => { + const rules: Record< string, any > = attributes.newspackAccessControlRules ?? {}; + const visibility: string = attributes.newspackAccessControlVisibility ?? 'visible'; + + const registration = rules.registration ?? {}; + const customAccess = rules.custom_access ?? {}; + // Flatten grouped OR rules for display: [[rule]] → [rule] + const activeRules: any[] = ( customAccess.access_rules ?? [] ).map( + ( group: any[] ) => group[ 0 ] + ).filter( Boolean ); + + const rulesActive = hasActiveRules( rules ); + + const updateRules = ( updates: Record< string, any > ) => { + const newRules = { ...rules, ...updates }; + const stillActive = hasActiveRules( newRules ); + setAttributes( { + newspackAccessControlRules: newRules, + // Reset visibility to 'visible' when all rules are cleared. + ...( ! stillActive ? { newspackAccessControlVisibility: 'visible' } : {} ), + } ); + }; + + const setRegistration = ( updates: Record< string, any > ) => { + const newRegistration = { ...registration, ...updates }; + // Remove requireVerification when registration is turned off. + if ( ! newRegistration.active ) { + newRegistration.require_verification = false; + } + updateRules( { registration: newRegistration } ); + }; + + const setAccessRules = ( flatRules: any[] ) => { + const grouped = flatRules.map( ( rule: any ) => [ rule ] ); + updateRules( { + custom_access: { + ...customAccess, + active: grouped.length > 0, + access_rules: grouped, + }, + } ); + }; + + return ( + + + { /* Visibility toggle — disabled until a rule is configured */ } +
+ + setAttributes( { newspackAccessControlVisibility: v } ) + } + /> +
+ + { /* Registration toggle */ } + + + { /* Access rule toggles */ } + +
+
+ ); +}; /** * Inject the Inspector panel into target block editors. From 9b6b3273ddcec5488758c7456587327d50cf0868 Mon Sep 17 00:00:00 2001 From: dkoo Date: Wed, 8 Apr 2026 09:55:21 -0600 Subject: [PATCH 14/34] feat(content-gate): add AccessRuleValueControl for block visibility panel --- src/content-gate/editor/block-visibility.tsx | 80 +++++++++++++++++++- 1 file changed, 78 insertions(+), 2 deletions(-) diff --git a/src/content-gate/editor/block-visibility.tsx b/src/content-gate/editor/block-visibility.tsx index e023464574..8fe845e96a 100644 --- a/src/content-gate/editor/block-visibility.tsx +++ b/src/content-gate/editor/block-visibility.tsx @@ -11,7 +11,11 @@ import { __experimentalToggleGroupControl as ToggleGroupControl, // eslint-disable-next-line @wordpress/no-unsafe-wp-apis __experimentalToggleGroupControlOption as ToggleGroupControlOption, + FormTokenField, + TextControl, } from '@wordpress/components'; +import { useState, useEffect } from '@wordpress/element'; +import apiFetch from '@wordpress/api-fetch'; import { __ } from '@wordpress/i18n'; /** @@ -81,9 +85,81 @@ const ToggleGroupControlCompat = ( { label, value, onChange }: any ) => ( ); /** - * Value control for a single access rule — stub, full implementation in Task 10. + * Rules whose options must be fetched dynamically. */ -const AccessRuleValueControl = ( _props: any ) => null; +const DYNAMIC_OPTION_RULES: Record< + string, + { path: string; mapItem: ( item: any ) => { value: string | number; label: string } } +> = { + institution: { + path: '/wp/v2/np_institution?per_page=100&context=edit', + mapItem: ( item: any ) => ( { value: item.id, label: item.title.raw } ), + }, +}; + +/** + * Value control for a single access rule. + * Renders FormTokenField for rules with options, TextControl for free-text rules. + */ +const AccessRuleValueControl = ( { slug, config, value, onChange }: any ) => { + const dynamicConfig = DYNAMIC_OPTION_RULES[ slug ]; + const staticOptions: Array< { value: string | number; label: string } > = + config.options ?? []; + + const [ options, setOptions ] = useState( staticOptions ); + + useEffect( () => { + if ( ! dynamicConfig ) return; + let cancelled = false; + apiFetch< any[] >( { path: dynamicConfig.path } ) + .then( items => { + if ( ! cancelled ) setOptions( items.map( dynamicConfig.mapItem ) ); + } ) + .catch( () => {} ); + return () => { + cancelled = true; + }; + }, [ slug ] ); // eslint-disable-line react-hooks/exhaustive-deps + + if ( options.length > 0 ) { + // Map stored IDs to labels for display; silently drop IDs with no matching option. + const selectedLabels = options + .filter( o => + ( Array.isArray( value ) ? value : [] ).some( + v => String( v ) === String( o.value ) + ) + ) + .map( o => o.label ); + + return ( + o.label ) } + onChange={ ( labels: string[] ) => + onChange( + options + .filter( o => labels.includes( o.label ) ) + .map( o => o.value ) + ) + } + __experimentalExpandOnFocus + __next40pxDefaultSize + /> + ); + } + + return ( + + ); +}; /** One toggle + value control per available access rule. */ const AccessRulesControls = ( { activeRules, onChange }: any ) => { From 28a509fe5249aa8c30ef8702daa3e0cee059a150 Mon Sep 17 00:00:00 2001 From: dkoo Date: Wed, 8 Apr 2026 10:13:46 -0600 Subject: [PATCH 15/34] fix(content-gate): handle is_boolean rules, use config.placeholder, fix setRegistration param --- src/content-gate/editor/block-visibility.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/content-gate/editor/block-visibility.tsx b/src/content-gate/editor/block-visibility.tsx index 8fe845e96a..585268958e 100644 --- a/src/content-gate/editor/block-visibility.tsx +++ b/src/content-gate/editor/block-visibility.tsx @@ -153,6 +153,7 @@ const AccessRuleValueControl = ( { slug, config, value, onChange }: any ) => { { onChange={ () => handleToggle( slug, config.default ) } __nextHasNoMarginBottom /> - { activeRule && ( + { activeRule && ! config.is_boolean && ( { } ); }; - const setRegistration = ( updates: Record< string, any > ) => { - const newRegistration = { ...registration, ...updates }; - // Remove requireVerification when registration is turned off. + const setRegistration = ( newRegistration: Record< string, any > ) => { + // Ensure require_verification is cleared when registration is turned off. if ( ! newRegistration.active ) { newRegistration.require_verification = false; } From 4b725c55cd2b30529e7bce5875c5f9154f1893f7 Mon Sep 17 00:00:00 2001 From: dkoo Date: Wed, 8 Apr 2026 10:18:11 -0600 Subject: [PATCH 16/34] test(content-gate): add JS unit tests for block visibility attribute filter --- .../editor/block-visibility.test.ts | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 src/content-gate/editor/block-visibility.test.ts diff --git a/src/content-gate/editor/block-visibility.test.ts b/src/content-gate/editor/block-visibility.test.ts new file mode 100644 index 0000000000..7312b6f63f --- /dev/null +++ b/src/content-gate/editor/block-visibility.test.ts @@ -0,0 +1,79 @@ +/** + * Tests for block-visibility attribute registration filter. + */ + +/** + * Capture callbacks registered via addFilter, keyed by namespace. + */ +const registeredFilters: Record< string, ( settings: any, name: string ) => any > = {}; + +jest.mock( '@wordpress/hooks', () => ( { + addFilter: jest.fn( + ( _hook: string, namespace: string, callback: ( settings: any, name: string ) => any ) => { + registeredFilters[ namespace ] = callback; + } + ), +} ) ); + +jest.mock( '@wordpress/compose', () => ( { + createHigherOrderComponent: jest.fn( ( fn: any ) => fn ), +} ) ); +jest.mock( '@wordpress/block-editor', () => ( { InspectorControls: () => null } ) ); +jest.mock( '@wordpress/components', () => ( {} ) ); +jest.mock( '@wordpress/i18n', () => ( { __: ( s: string ) => s } ) ); +jest.mock( '@wordpress/element', () => ( { + useState: jest.fn( ( v: any ) => [ v, jest.fn() ] ), + useEffect: jest.fn(), +} ) ); +jest.mock( '@wordpress/api-fetch', () => jest.fn() ); + +// Importing the module triggers the addFilter side effects. +require( './block-visibility' ); + +const attributeFilter = + registeredFilters[ 'newspack-plugin/block-visibility/attributes' ]; + +describe( 'block-visibility attribute registration', () => { + it( 'adds attributes to core/group', () => { + const result = attributeFilter( { attributes: {} }, 'core/group' ); + expect( result.attributes ).toHaveProperty( 'newspackAccessControlVisibility' ); + expect( result.attributes ).toHaveProperty( 'newspackAccessControlRules' ); + } ); + + it( 'adds attributes to core/stack', () => { + const result = attributeFilter( { attributes: {} }, 'core/stack' ); + expect( result.attributes ).toHaveProperty( 'newspackAccessControlVisibility' ); + expect( result.attributes ).toHaveProperty( 'newspackAccessControlRules' ); + } ); + + it( 'adds attributes to core/row', () => { + const result = attributeFilter( { attributes: {} }, 'core/row' ); + expect( result.attributes ).toHaveProperty( 'newspackAccessControlVisibility' ); + expect( result.attributes ).toHaveProperty( 'newspackAccessControlRules' ); + } ); + + it( 'does not modify non-target blocks', () => { + const settings = { attributes: { align: { type: 'string' } } }; + const result = attributeFilter( settings, 'core/paragraph' ); + expect( result ).toBe( settings ); + } ); + + it( 'newspackAccessControlVisibility defaults to visible', () => { + const result = attributeFilter( { attributes: {} }, 'core/group' ); + expect( result.attributes.newspackAccessControlVisibility.default ).toBe( 'visible' ); + } ); + + it( 'newspackAccessControlRules defaults to empty object', () => { + const result = attributeFilter( { attributes: {} }, 'core/group' ); + expect( result.attributes.newspackAccessControlRules.default ).toEqual( {} ); + } ); + + it( 'preserves existing attributes on target blocks', () => { + const result = attributeFilter( + { attributes: { align: { type: 'string' } } }, + 'core/group' + ); + expect( result.attributes ).toHaveProperty( 'align' ); + expect( result.attributes ).toHaveProperty( 'newspackAccessControlVisibility' ); + } ); +} ); From 1af1acbb6288cbc14843cb0dcb3a5bfd546b60a4 Mon Sep 17 00:00:00 2001 From: dkoo Date: Wed, 8 Apr 2026 11:29:05 -0600 Subject: [PATCH 17/34] style: tweak styles for Access Control block visibility panel --- .../editor/block-visibility.test.ts | 16 +- src/content-gate/editor/block-visibility.tsx | 263 ++++++++---------- src/content-gate/editor/editor.scss | 22 ++ src/content-gate/editor/index.d.ts | 28 ++ 4 files changed, 175 insertions(+), 154 deletions(-) create mode 100644 src/content-gate/editor/index.d.ts diff --git a/src/content-gate/editor/block-visibility.test.ts b/src/content-gate/editor/block-visibility.test.ts index 7312b6f63f..da1b14a3b0 100644 --- a/src/content-gate/editor/block-visibility.test.ts +++ b/src/content-gate/editor/block-visibility.test.ts @@ -8,11 +8,9 @@ const registeredFilters: Record< string, ( settings: any, name: string ) => any > = {}; jest.mock( '@wordpress/hooks', () => ( { - addFilter: jest.fn( - ( _hook: string, namespace: string, callback: ( settings: any, name: string ) => any ) => { - registeredFilters[ namespace ] = callback; - } - ), + addFilter: jest.fn( ( _hook: string, namespace: string, callback: ( settings: any, name: string ) => any ) => { + registeredFilters[ namespace ] = callback; + } ), } ) ); jest.mock( '@wordpress/compose', () => ( { @@ -30,8 +28,7 @@ jest.mock( '@wordpress/api-fetch', () => jest.fn() ); // Importing the module triggers the addFilter side effects. require( './block-visibility' ); -const attributeFilter = - registeredFilters[ 'newspack-plugin/block-visibility/attributes' ]; +const attributeFilter = registeredFilters[ 'newspack-plugin/block-visibility/attributes' ]; describe( 'block-visibility attribute registration', () => { it( 'adds attributes to core/group', () => { @@ -69,10 +66,7 @@ describe( 'block-visibility attribute registration', () => { } ); it( 'preserves existing attributes on target blocks', () => { - const result = attributeFilter( - { attributes: { align: { type: 'string' } } }, - 'core/group' - ); + const result = attributeFilter( { attributes: { align: { type: 'string' } } }, 'core/group' ); expect( result.attributes ).toHaveProperty( 'align' ); expect( result.attributes ).toHaveProperty( 'newspackAccessControlVisibility' ); } ); diff --git a/src/content-gate/editor/block-visibility.tsx b/src/content-gate/editor/block-visibility.tsx index 585268958e..421bae34a8 100644 --- a/src/content-gate/editor/block-visibility.tsx +++ b/src/content-gate/editor/block-visibility.tsx @@ -5,19 +5,29 @@ import { addFilter } from '@wordpress/hooks'; import { createHigherOrderComponent } from '@wordpress/compose'; import { InspectorControls } from '@wordpress/block-editor'; import { + CheckboxControl, + FormTokenField, PanelBody, + PanelRow, + TextControl, ToggleControl, // eslint-disable-next-line @wordpress/no-unsafe-wp-apis __experimentalToggleGroupControl as ToggleGroupControl, // eslint-disable-next-line @wordpress/no-unsafe-wp-apis __experimentalToggleGroupControlOption as ToggleGroupControlOption, - FormTokenField, - TextControl, } from '@wordpress/components'; import { useState, useEffect } from '@wordpress/element'; import apiFetch from '@wordpress/api-fetch'; import { __ } from '@wordpress/i18n'; +/** + * Internal dependencies + */ +import './editor.scss'; + +/** + */ + /** * Target block types that receive access control attributes. */ @@ -26,35 +36,30 @@ const TARGET_BLOCKS = [ 'core/group', 'core/stack', 'core/row' ]; /** * Register custom attributes on target block types. */ -addFilter( - 'blocks.registerBlockType', - 'newspack-plugin/block-visibility/attributes', - ( settings: any, name: string ) => { - if ( ! TARGET_BLOCKS.includes( name ) ) { - return settings; - } - return { - ...settings, - attributes: { - ...settings.attributes, - newspackAccessControlVisibility: { - type: 'string', - default: 'visible', - }, - newspackAccessControlRules: { - type: 'object', - default: {}, - }, - }, - }; +addFilter( 'blocks.registerBlockType', 'newspack-plugin/block-visibility/attributes', ( settings: BlockSettings, name: string ) => { + if ( ! TARGET_BLOCKS.includes( name ) ) { + return settings; } -); + return { + ...settings, + attributes: { + ...settings.attributes, + newspackAccessControlVisibility: { + type: 'string', + default: 'visible', + }, + newspackAccessControlRules: { + type: 'object', + default: {}, + }, + }, + }; +} ); /** * Available access rules from localized data. */ -const availableAccessRules: Record< string, any > = - ( window as any ).newspackBlockVisibility?.available_access_rules ?? {}; +const availableAccessRules: Record< string, AccessRuleConfig > = window.newspackBlockVisibility?.available_access_rules ?? {}; /** * Whether any rules are currently active on the block. @@ -63,37 +68,35 @@ function hasActiveRules( rules: Record< string, any > ): boolean { return !! rules?.registration?.active || !! rules?.custom_access?.active; } -/** Wraps ToggleGroupControl with the two standard visibility options. */ -const ToggleGroupControlCompat = ( { label, value, onChange }: any ) => ( - - - - +/** ToggleGroupControl for the two standard visibility options. */ +const VisibilityControl = ( { + label, + help, + value, + onChange, + disabled, +}: { + label: string; + help: string; + value: string; + onChange: ( value: string ) => void; + disabled: boolean; +} ) => ( + + + + + + ); /** * Rules whose options must be fetched dynamically. */ -const DYNAMIC_OPTION_RULES: Record< - string, - { path: string; mapItem: ( item: any ) => { value: string | number; label: string } } -> = { +const DYNAMIC_OPTION_RULES: Record< string, { path: string; mapItem: ( item: DynamicOptionItem ) => { value: string | number; label: string } } > = { institution: { path: '/wp/v2/np_institution?per_page=100&context=edit', - mapItem: ( item: any ) => ( { value: item.id, label: item.title.raw } ), + mapItem: ( item: DynamicOptionItem ) => ( { value: item.id, label: item.title.raw } ), }, }; @@ -103,17 +106,20 @@ const DYNAMIC_OPTION_RULES: Record< */ const AccessRuleValueControl = ( { slug, config, value, onChange }: any ) => { const dynamicConfig = DYNAMIC_OPTION_RULES[ slug ]; - const staticOptions: Array< { value: string | number; label: string } > = - config.options ?? []; + const staticOptions: Array< { value: string | number; label: string } > = config.options ?? []; const [ options, setOptions ] = useState( staticOptions ); useEffect( () => { - if ( ! dynamicConfig ) return; + if ( ! dynamicConfig ) { + return; + } let cancelled = false; apiFetch< any[] >( { path: dynamicConfig.path } ) .then( items => { - if ( ! cancelled ) setOptions( items.map( dynamicConfig.mapItem ) ); + if ( ! cancelled ) { + setOptions( items.map( dynamicConfig.mapItem ) ); + } } ) .catch( () => {} ); return () => { @@ -124,11 +130,7 @@ const AccessRuleValueControl = ( { slug, config, value, onChange }: any ) => { if ( options.length > 0 ) { // Map stored IDs to labels for display; silently drop IDs with no matching option. const selectedLabels = options - .filter( o => - ( Array.isArray( value ) ? value : [] ).some( - v => String( v ) === String( o.value ) - ) - ) + .filter( o => ( Array.isArray( value ) ? value : [] ).some( v => String( v ) === String( o.value ) ) ) .map( o => o.label ); return ( @@ -136,13 +138,7 @@ const AccessRuleValueControl = ( { slug, config, value, onChange }: any ) => { label="" value={ selectedLabels } suggestions={ options.map( o => o.label ) } - onChange={ ( labels: string[] ) => - onChange( - options - .filter( o => labels.includes( o.label ) ) - .map( o => o.value ) - ) - } + onChange={ ( labels: string[] ) => onChange( options.filter( o => labels.includes( o.label ) ).map( o => o.value ) ) } __experimentalExpandOnFocus __next40pxDefaultSize /> @@ -182,23 +178,24 @@ const AccessRulesControls = ( { activeRules, onChange }: any ) => { { Object.entries( availableAccessRules ).map( ( [ slug, config ]: [ string, any ] ) => { const activeRule = activeRules.find( ( r: any ) => r.slug === slug ); return ( -
- handleToggle( slug, config.default ) } - __nextHasNoMarginBottom - /> - { activeRule && ! config.is_boolean && ( - handleValueChange( slug, v ) } + +
+ handleToggle( slug, config.default ) } /> - ) } -
+ { activeRule && ! config.is_boolean && ( + handleValueChange( slug, v ) } + /> + ) } +
+ ); } ) } @@ -207,25 +204,24 @@ const AccessRulesControls = ( { activeRules, onChange }: any ) => { /** Registration section: logged-in toggle + optional verification sub-toggle. */ const RegistrationControls = ( { registration, onChange }: any ) => ( - <> - onChange( { ...registration, active } ) } - __nextHasNoMarginBottom - /> - { registration.active && ( + +
- onChange( { ...registration, require_verification } ) - } - __nextHasNoMarginBottom + label={ __( 'Registered readers', 'newspack-plugin' ) } + help={ __( 'Restrict to logged-in readers.', 'newspack-plugin' ) } + checked={ !! registration.active } + onChange={ active => onChange( { ...registration, active } ) } /> - ) } - + { registration.active && ( + onChange( { ...registration, require_verification } ) } + /> + ) } +
+
); /** @@ -238,9 +234,7 @@ const BlockVisibilityPanel = ( { attributes, setAttributes }: any ) => { const registration = rules.registration ?? {}; const customAccess = rules.custom_access ?? {}; // Flatten grouped OR rules for display: [[rule]] → [rule] - const activeRules: any[] = ( customAccess.access_rules ?? [] ).map( - ( group: any[] ) => group[ 0 ] - ).filter( Boolean ); + const activeRules: any[] = ( customAccess.access_rules ?? [] ).map( ( group: any[] ) => group[ 0 ] ).filter( Boolean ); const rulesActive = hasActiveRules( rules ); @@ -276,37 +270,23 @@ const BlockVisibilityPanel = ( { attributes, setAttributes }: any ) => { return ( - { /* Visibility toggle — disabled until a rule is configured */ } -
- - setAttributes( { newspackAccessControlVisibility: v } ) - } - /> -
+ setAttributes( { newspackAccessControlVisibility: v } ) } + disabled={ ! rulesActive } + /> { /* Registration toggle */ } - + { /* Access rule toggles */ } - +
); @@ -318,21 +298,18 @@ const BlockVisibilityPanel = ( { attributes, setAttributes }: any ) => { addFilter( 'editor.BlockEdit', 'newspack-plugin/block-visibility/inspector', - createHigherOrderComponent( - BlockEdit => { - const WithBlockVisibilityPanel = ( props: any ) => { - if ( ! TARGET_BLOCKS.includes( props.name ) ) { - return ; - } - return ( - <> - - - - ); - }; - return WithBlockVisibilityPanel; - }, - 'withBlockVisibilityPanel' - ) + createHigherOrderComponent( BlockEdit => { + const WithBlockVisibilityPanel = ( props: any ) => { + if ( ! TARGET_BLOCKS.includes( props.name ) ) { + return ; + } + return ( + <> + + + + ); + }; + return WithBlockVisibilityPanel; + }, 'withBlockVisibilityPanel' ) ); diff --git a/src/content-gate/editor/editor.scss b/src/content-gate/editor/editor.scss index 62ef38c11d..2784ea4576 100644 --- a/src/content-gate/editor/editor.scss +++ b/src/content-gate/editor/editor.scss @@ -56,3 +56,25 @@ .post-type-np_gate_layout .editor-post-title { display: none; } + +/** + * Access control block visibility panel. + */ +.newspack-access-control-block-visibility-panel { + .components-toggle-control { + margin-top: 8px; + width: 100%; + .components-base-control__field { + margin-bottom: 4px; + } + .components-form-toggle { + order: 2; + } + .components-base-control__help { + margin-top: 0; + } + .components-toggle-control__help { + margin-inline-start: auto; + } + } +} diff --git a/src/content-gate/editor/index.d.ts b/src/content-gate/editor/index.d.ts new file mode 100644 index 0000000000..7df61b211d --- /dev/null +++ b/src/content-gate/editor/index.d.ts @@ -0,0 +1,28 @@ +/** + * Types. + */ +type BlockSettings = { + attributes: Record; + name: string; +}; +type DynamicOptionItem = { + id: string | number; + title: { + raw: string; + }; +}; +type AccessRuleConfig = { + name: string; + description: string; + default: string | Array; + is_boolean: boolean; + options: Array<{ value: string | number; label: string }>; +}; + +declare global { + interface Window { + newspackBlockVisibility: { + available_access_rules: Record; + }; + } +} \ No newline at end of file From 1ea2dc82bede8b1f7b939969801544339dbb6a9f Mon Sep 17 00:00:00 2001 From: dkoo Date: Wed, 8 Apr 2026 13:12:41 -0600 Subject: [PATCH 18/34] refactor(content-gate): replace any with proper types in block-visibility --- src/content-gate/editor/block-visibility.tsx | 91 ++++++++++++-------- src/content-gate/editor/index.d.ts | 54 +++++++++--- 2 files changed, 100 insertions(+), 45 deletions(-) diff --git a/src/content-gate/editor/block-visibility.tsx b/src/content-gate/editor/block-visibility.tsx index 421bae34a8..d0d642c457 100644 --- a/src/content-gate/editor/block-visibility.tsx +++ b/src/content-gate/editor/block-visibility.tsx @@ -25,9 +25,6 @@ import { __ } from '@wordpress/i18n'; */ import './editor.scss'; -/** - */ - /** * Target block types that receive access control attributes. */ @@ -64,7 +61,7 @@ const availableAccessRules: Record< string, AccessRuleConfig > = window.newspack /** * Whether any rules are currently active on the block. */ -function hasActiveRules( rules: Record< string, any > ): boolean { +function hasActiveRules( rules: BlockVisibilityRules ): boolean { return !! rules?.registration?.active || !! rules?.custom_access?.active; } @@ -83,7 +80,7 @@ const VisibilityControl = ( { disabled: boolean; } ) => ( - + onChange( String( v ?? 'visible' ) ) } isBlock __next40pxDefaultSize> @@ -93,7 +90,7 @@ const VisibilityControl = ( { /** * Rules whose options must be fetched dynamically. */ -const DYNAMIC_OPTION_RULES: Record< string, { path: string; mapItem: ( item: DynamicOptionItem ) => { value: string | number; label: string } } > = { +const DYNAMIC_OPTION_RULES: Record< string, { path: string; mapItem: ( item: DynamicOptionItem ) => AccessRuleOption } > = { institution: { path: '/wp/v2/np_institution?per_page=100&context=edit', mapItem: ( item: DynamicOptionItem ) => ( { value: item.id, label: item.title.raw } ), @@ -104,18 +101,28 @@ const DYNAMIC_OPTION_RULES: Record< string, { path: string; mapItem: ( item: Dyn * Value control for a single access rule. * Renders FormTokenField for rules with options, TextControl for free-text rules. */ -const AccessRuleValueControl = ( { slug, config, value, onChange }: any ) => { +const AccessRuleValueControl = ( { + slug, + config, + value, + onChange, +}: { + slug: string; + config: AccessRuleConfig; + value: ActiveRule[ 'value' ]; + onChange: ( value: ActiveRule[ 'value' ] ) => void; +} ) => { const dynamicConfig = DYNAMIC_OPTION_RULES[ slug ]; - const staticOptions: Array< { value: string | number; label: string } > = config.options ?? []; + const staticOptions: AccessRuleOption[] = config.options ?? []; - const [ options, setOptions ] = useState( staticOptions ); + const [ options, setOptions ] = useState< AccessRuleOption[] >( staticOptions ); useEffect( () => { if ( ! dynamicConfig ) { return; } let cancelled = false; - apiFetch< any[] >( { path: dynamicConfig.path } ) + apiFetch< DynamicOptionItem[] >( { path: dynamicConfig.path } ) .then( items => { if ( ! cancelled ) { setOptions( items.map( dynamicConfig.mapItem ) ); @@ -129,8 +136,9 @@ const AccessRuleValueControl = ( { slug, config, value, onChange }: any ) => { if ( options.length > 0 ) { // Map stored IDs to labels for display; silently drop IDs with no matching option. + const valueArr = Array.isArray( value ) ? value : []; const selectedLabels = options - .filter( o => ( Array.isArray( value ) ? value : [] ).some( v => String( v ) === String( o.value ) ) ) + .filter( o => valueArr.some( v => String( v ) === String( o.value ) ) ) .map( o => o.label ); return ( @@ -138,7 +146,10 @@ const AccessRuleValueControl = ( { slug, config, value, onChange }: any ) => { label="" value={ selectedLabels } suggestions={ options.map( o => o.label ) } - onChange={ ( labels: string[] ) => onChange( options.filter( o => labels.includes( o.label ) ).map( o => o.value ) ) } + onChange={ ( tokens: ( string | { value: string } )[] ) => { + const labels = tokens.map( t => ( typeof t === 'string' ? t : t.value ) ); + onChange( options.filter( o => labels.includes( o.label ) ).map( o => o.value ) ); + } } __experimentalExpandOnFocus __next40pxDefaultSize /> @@ -152,31 +163,37 @@ const AccessRuleValueControl = ( { slug, config, value, onChange }: any ) => { placeholder={ config.placeholder ?? '' } help={ __( 'Separate with commas.', 'newspack-plugin' ) } value={ typeof value === 'string' ? value : '' } - onChange={ onChange } + onChange={ onChange as ( value: string ) => void } __next40pxDefaultSize /> ); }; /** One toggle + value control per available access rule. */ -const AccessRulesControls = ( { activeRules, onChange }: any ) => { - const handleToggle = ( slug: string, defaultValue: any ) => { - const has = activeRules.some( ( r: any ) => r.slug === slug ); +const AccessRulesControls = ( { + activeRules, + onChange, +}: { + activeRules: ActiveRule[]; + onChange: ( rules: ActiveRule[] ) => void; +} ) => { + const handleToggle = ( slug: string, defaultValue: ActiveRule[ 'value' ] ) => { + const has = activeRules.some( r => r.slug === slug ); if ( has ) { - onChange( activeRules.filter( ( r: any ) => r.slug !== slug ) ); + onChange( activeRules.filter( r => r.slug !== slug ) ); } else { onChange( [ ...activeRules, { slug, value: defaultValue } ] ); } }; - const handleValueChange = ( slug: string, value: any ) => { - onChange( activeRules.map( ( r: any ) => ( r.slug === slug ? { ...r, value } : r ) ) ); + const handleValueChange = ( slug: string, value: ActiveRule[ 'value' ] ) => { + onChange( activeRules.map( r => ( r.slug === slug ? { ...r, value } : r ) ) ); }; return ( <> - { Object.entries( availableAccessRules ).map( ( [ slug, config ]: [ string, any ] ) => { - const activeRule = activeRules.find( ( r: any ) => r.slug === slug ); + { Object.entries( availableAccessRules ).map( ( [ slug, config ] ) => { + const activeRule = activeRules.find( r => r.slug === slug ); return (
@@ -191,7 +208,7 @@ const AccessRulesControls = ( { activeRules, onChange }: any ) => { slug={ slug } config={ config } value={ activeRule.value } - onChange={ ( v: any ) => handleValueChange( slug, v ) } + onChange={ v => handleValueChange( slug, v ) } /> ) }
@@ -203,7 +220,13 @@ const AccessRulesControls = ( { activeRules, onChange }: any ) => { }; /** Registration section: logged-in toggle + optional verification sub-toggle. */ -const RegistrationControls = ( { registration, onChange }: any ) => ( +const RegistrationControls = ( { + registration, + onChange, +}: { + registration: RegistrationRule; + onChange: ( registration: RegistrationRule ) => void; +} ) => (
( /** * Inspector panel for block access control. */ -const BlockVisibilityPanel = ( { attributes, setAttributes }: any ) => { - const rules: Record< string, any > = attributes.newspackAccessControlRules ?? {}; +const BlockVisibilityPanel = ( { attributes, setAttributes }: BlockEditProps ) => { + const rules: BlockVisibilityRules = attributes.newspackAccessControlRules ?? {}; const visibility: string = attributes.newspackAccessControlVisibility ?? 'visible'; - const registration = rules.registration ?? {}; - const customAccess = rules.custom_access ?? {}; + const registration: RegistrationRule = rules.registration ?? { active: false }; + const customAccess: CustomAccessRule = rules.custom_access ?? { active: false, access_rules: [] }; // Flatten grouped OR rules for display: [[rule]] → [rule] - const activeRules: any[] = ( customAccess.access_rules ?? [] ).map( ( group: any[] ) => group[ 0 ] ).filter( Boolean ); + const activeRules: ActiveRule[] = customAccess.access_rules.map( group => group[ 0 ] ).filter( Boolean ); const rulesActive = hasActiveRules( rules ); - const updateRules = ( updates: Record< string, any > ) => { - const newRules = { ...rules, ...updates }; + const updateRules = ( updates: Partial< BlockVisibilityRules > ) => { + const newRules: BlockVisibilityRules = { ...rules, ...updates }; const stillActive = hasActiveRules( newRules ); setAttributes( { newspackAccessControlRules: newRules, @@ -248,7 +271,7 @@ const BlockVisibilityPanel = ( { attributes, setAttributes }: any ) => { } ); }; - const setRegistration = ( newRegistration: Record< string, any > ) => { + const setRegistration = ( newRegistration: RegistrationRule ) => { // Ensure require_verification is cleared when registration is turned off. if ( ! newRegistration.active ) { newRegistration.require_verification = false; @@ -256,8 +279,8 @@ const BlockVisibilityPanel = ( { attributes, setAttributes }: any ) => { updateRules( { registration: newRegistration } ); }; - const setAccessRules = ( flatRules: any[] ) => { - const grouped = flatRules.map( ( rule: any ) => [ rule ] ); + const setAccessRules = ( flatRules: ActiveRule[] ) => { + const grouped: ActiveRule[][] = flatRules.map( rule => [ rule ] ); updateRules( { custom_access: { ...customAccess, @@ -299,7 +322,7 @@ addFilter( 'editor.BlockEdit', 'newspack-plugin/block-visibility/inspector', createHigherOrderComponent( BlockEdit => { - const WithBlockVisibilityPanel = ( props: any ) => { + const WithBlockVisibilityPanel = ( props: BlockEditProps ) => { if ( ! TARGET_BLOCKS.includes( props.name ) ) { return ; } diff --git a/src/content-gate/editor/index.d.ts b/src/content-gate/editor/index.d.ts index 7df61b211d..4bdad1a93b 100644 --- a/src/content-gate/editor/index.d.ts +++ b/src/content-gate/editor/index.d.ts @@ -1,8 +1,10 @@ +declare module '@wordpress/block-editor'; + /** * Types. */ type BlockSettings = { - attributes: Record; + attributes: Record< string, unknown >; name: string; }; type DynamicOptionItem = { @@ -11,18 +13,48 @@ type DynamicOptionItem = { raw: string; }; }; +type AccessRuleOption = { + value: string | number; + label: string; +}; type AccessRuleConfig = { name: string; description: string; - default: string | Array; - is_boolean: boolean; - options: Array<{ value: string | number; label: string }>; + default: string | Array< string | number >; + is_boolean?: boolean; + placeholder?: string; + options?: AccessRuleOption[]; +}; +type ActiveRule = { + slug: string; + value: string | Array< string | number > | null; +}; +type RegistrationRule = { + active: boolean; + require_verification?: boolean; +}; +type CustomAccessRule = { + active: boolean; + access_rules: ActiveRule[][]; +}; +type BlockVisibilityRules = { + registration?: RegistrationRule; + custom_access?: CustomAccessRule; +}; +type BlockVisibilityAttributes = { + newspackAccessControlRules: BlockVisibilityRules; + newspackAccessControlVisibility: string; + [ key: string ]: unknown; +}; +type BlockEditProps = { + name: string; + attributes: BlockVisibilityAttributes; + setAttributes: ( attrs: Partial< BlockVisibilityAttributes > ) => void; + [ key: string ]: unknown; }; -declare global { - interface Window { - newspackBlockVisibility: { - available_access_rules: Record; - }; - } -} \ No newline at end of file +interface Window { + newspackBlockVisibility: { + available_access_rules: Record< string, AccessRuleConfig >; + }; +} From 323b7aa6dc9af2017bc01b275dff8634c4031c38 Mon Sep 17 00:00:00 2001 From: dkoo Date: Wed, 8 Apr 2026 13:14:43 -0600 Subject: [PATCH 19/34] refactor: prettier formatting --- src/content-gate/editor/block-visibility.tsx | 21 ++++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/content-gate/editor/block-visibility.tsx b/src/content-gate/editor/block-visibility.tsx index d0d642c457..a6ddf3e988 100644 --- a/src/content-gate/editor/block-visibility.tsx +++ b/src/content-gate/editor/block-visibility.tsx @@ -80,7 +80,14 @@ const VisibilityControl = ( { disabled: boolean; } ) => ( - onChange( String( v ?? 'visible' ) ) } isBlock __next40pxDefaultSize> + onChange( String( v ?? 'visible' ) ) } + isBlock + __next40pxDefaultSize + > @@ -137,9 +144,7 @@ const AccessRuleValueControl = ( { if ( options.length > 0 ) { // Map stored IDs to labels for display; silently drop IDs with no matching option. const valueArr = Array.isArray( value ) ? value : []; - const selectedLabels = options - .filter( o => valueArr.some( v => String( v ) === String( o.value ) ) ) - .map( o => o.label ); + const selectedLabels = options.filter( o => valueArr.some( v => String( v ) === String( o.value ) ) ).map( o => o.label ); return ( void; -} ) => { +const AccessRulesControls = ( { activeRules, onChange }: { activeRules: ActiveRule[]; onChange: ( rules: ActiveRule[] ) => void } ) => { const handleToggle = ( slug: string, defaultValue: ActiveRule[ 'value' ] ) => { const has = activeRules.some( r => r.slug === slug ); if ( has ) { From 553a4ca4391853e8de0a4e4826126f0d76fd1f96 Mon Sep 17 00:00:00 2001 From: dkoo Date: Wed, 8 Apr 2026 13:54:41 -0600 Subject: [PATCH 20/34] fix(content-gate): address code review issues in block visibility - Use [] not stdClass as default for newspackAccessControlRules block attribute - Add comment on get_post_type() === false in enqueue_block_editor_assets - Fix setRegistration to not mutate its parameter; use immutable spread - Fix counting_rule test to use unique ID per run to avoid closure issues - Fix "Visiblity" typo in VisibilityControl help text - Guard test_rule registration against duplicate registration across tests Co-Authored-By: Claude Sonnet 4.6 --- .../content-gate/class-block-visibility.php | 5 +- src/content-gate/editor/block-visibility.tsx | 14 ++-- .../content-gate/class-block-visibility.php | 72 ++++++++++++++----- 3 files changed, 65 insertions(+), 26 deletions(-) diff --git a/includes/content-gate/class-block-visibility.php b/includes/content-gate/class-block-visibility.php index 51f3c80353..21bbe6346f 100644 --- a/includes/content-gate/class-block-visibility.php +++ b/includes/content-gate/class-block-visibility.php @@ -85,7 +85,7 @@ public static function register_block_type_args( $args, $block_type ) { ], 'newspackAccessControlRules' => [ 'type' => 'object', - 'default' => new \stdClass(), + 'default' => [], ], ] ); @@ -104,6 +104,9 @@ public static function enqueue_block_editor_assets() { Content_Restriction_Control::get_available_post_types(), 'value' ); + // get_post_type() returns false in the Site Editor / widget screens where + // no post is in context — in_array( false, [...], true ) is false, so the + // asset is correctly suppressed. This mirrors the guard in Content_Gate. if ( ! in_array( get_post_type(), $available_post_types, true ) ) { return; } diff --git a/src/content-gate/editor/block-visibility.tsx b/src/content-gate/editor/block-visibility.tsx index a6ddf3e988..e6be0223f3 100644 --- a/src/content-gate/editor/block-visibility.tsx +++ b/src/content-gate/editor/block-visibility.tsx @@ -271,11 +271,13 @@ const BlockVisibilityPanel = ( { attributes, setAttributes }: BlockEditProps ) = }; const setRegistration = ( newRegistration: RegistrationRule ) => { - // Ensure require_verification is cleared when registration is turned off. - if ( ! newRegistration.active ) { - newRegistration.require_verification = false; - } - updateRules( { registration: newRegistration } ); + updateRules( { + registration: { + ...newRegistration, + // Ensure require_verification is cleared when registration is turned off. + require_verification: newRegistration.active ? newRegistration.require_verification : false, + }, + } ); }; const setAccessRules = ( flatRules: ActiveRule[] ) => { @@ -298,7 +300,7 @@ const BlockVisibilityPanel = ( { attributes, setAttributes }: BlockEditProps ) = > setAttributes( { newspackAccessControlVisibility: v } ) } disabled={ ! rulesActive } diff --git a/tests/unit-tests/content-gate/class-block-visibility.php b/tests/unit-tests/content-gate/class-block-visibility.php index 4aef7787f4..47aeb503db 100644 --- a/tests/unit-tests/content-gate/class-block-visibility.php +++ b/tests/unit-tests/content-gate/class-block-visibility.php @@ -28,15 +28,20 @@ public function set_up() { $this->test_user_id = $this->factory->user->create( [ 'role' => 'subscriber' ] ); // Register a simple test rule: passes only for our test user. - \Newspack\Access_Rules::register_rule( - [ - 'id' => 'test_rule', - 'name' => 'Test Rule', - 'callback' => function( $user_id, $value ) { - return intval( $user_id ) === intval( $value ); - }, - ] - ); + // Guard against duplicate registration: Access_Rules::$rules is static and + // persists across test methods within the same PHP process. + $registered = \Newspack\Access_Rules::get_registered_rules(); + if ( ! isset( $registered['test_rule'] ) ) { + \Newspack\Access_Rules::register_rule( + [ + 'id' => 'test_rule', + 'name' => 'Test Rule', + 'callback' => function( $user_id, $value ) { + return intval( $user_id ) === intval( $value ); + }, + ] + ); + } } /** @@ -215,7 +220,14 @@ public function test_access_rule_matching_user_passes() { $rules = [ 'custom_access' => [ 'active' => true, - 'access_rules' => [ [ [ 'slug' => 'test_rule', 'value' => $this->test_user_id ] ] ], + 'access_rules' => [ + [ + [ + 'slug' => 'test_rule', + 'value' => $this->test_user_id, + ], + ], + ], ], ]; $this->assertTrue( Block_Visibility::evaluate_rules_for_user_public( $rules, $this->test_user_id ) ); @@ -229,7 +241,14 @@ public function test_access_rule_non_matching_user_fails() { $rules = [ 'custom_access' => [ 'active' => true, - 'access_rules' => [ [ [ 'slug' => 'test_rule', 'value' => $this->test_user_id ] ] ], + 'access_rules' => [ + [ + [ + 'slug' => 'test_rule', + 'value' => $this->test_user_id, + ], + ], + ], ], ]; $this->assertFalse( Block_Visibility::evaluate_rules_for_user_public( $rules, $other_user ) ); @@ -243,7 +262,14 @@ public function test_and_logic_both_must_pass() { 'registration' => [ 'active' => true ], 'custom_access' => [ 'active' => true, - 'access_rules' => [ [ [ 'slug' => 'test_rule', 'value' => $this->test_user_id ] ] ], + 'access_rules' => [ + [ + [ + 'slug' => 'test_rule', + 'value' => $this->test_user_id, + ], + ], + ], ], ]; // Logged-in user who matches the access rule: passes. @@ -341,20 +367,20 @@ public function test_all_target_block_types_evaluated() { public function test_missing_visibility_attribute_defaults_to_visible() { wp_set_current_user( 0 ); Block_Visibility::reset_cache_for_tests(); - $block = $this->make_block( + $block = $this->make_block( 'core/group', [ 'newspackAccessControlRules' => [ 'registration' => [ 'active' => true ] ], - // newspackAccessControlVisibility intentionally omitted + // newspackAccessControlVisibility intentionally omitted. ] ); $result = Block_Visibility::filter_render_block( '
x
', $block ); - // logged-out user: rules don't match, so hidden under default "visible" mode + // Logged-out user: rules don't match, so hidden under default "visible" mode. $this->assertSame( '', $result ); } /** - * core/group block has both visibility attributes registered server-side. + * Core/group block has both visibility attributes registered server-side. */ public function test_group_block_has_visibility_attribute_registered() { $block_type = \WP_Block_Type_Registry::get_instance()->get_registered( 'core/group' ); @@ -366,10 +392,11 @@ public function test_group_block_has_visibility_attribute_registered() { * Caching: second call returns cached result without re-evaluation. */ public function test_result_is_cached() { - $call_count = 0; + $call_count = 0; + $counting_rule_id = 'counting_rule_' . uniqid(); \Newspack\Access_Rules::register_rule( [ - 'id' => 'counting_rule', + 'id' => $counting_rule_id, 'name' => 'Counting Rule', 'callback' => function( $user_id, $value ) use ( &$call_count ) { $call_count++; @@ -380,7 +407,14 @@ public function test_result_is_cached() { $rules = [ 'custom_access' => [ 'active' => true, - 'access_rules' => [ [ [ 'slug' => 'counting_rule', 'value' => null ] ] ], + 'access_rules' => [ + [ + [ + 'slug' => $counting_rule_id, + 'value' => null, + ], + ], + ], ], ]; Block_Visibility::evaluate_rules_for_user_public( $rules, $this->test_user_id ); From 1c141f2b8542957397142a39104b7e23ec056d33 Mon Sep 17 00:00:00 2001 From: dkoo Date: Wed, 8 Apr 2026 14:05:36 -0600 Subject: [PATCH 21/34] fix(content-gate): address Copilot review feedback on block visibility - Bypass access control during REST requests so blocks are never hidden in the editor's block renderer or preview contexts - Defensively cast newspackAccessControlRules to array in case the block parser yields a stdClass for the object-typed attribute - Fix FormTokenField empty label; use config.name + hideLabelFromVision Co-Authored-By: Claude Sonnet 4.6 --- includes/content-gate/class-block-visibility.php | 13 ++++++++++++- src/content-gate/editor/block-visibility.tsx | 3 ++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/includes/content-gate/class-block-visibility.php b/includes/content-gate/class-block-visibility.php index 21bbe6346f..9fca527ba9 100644 --- a/includes/content-gate/class-block-visibility.php +++ b/includes/content-gate/class-block-visibility.php @@ -38,12 +38,23 @@ public static function filter_render_block( $block_content, $block ) { return $block_content; } - if ( is_admin() ) { + // Bypass access control in admin screens and REST requests (block renderer, + // preview, query-loop rendering inside the editor) so blocks are never hidden + // from editors during content authoring. + if ( is_admin() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) { return $block_content; } $rules = $block['attrs']['newspackAccessControlRules'] ?? []; + // Defensive cast: the block parser can occasionally yield a stdClass for + // object-typed attributes (e.g. after JSON round-trips). + if ( is_object( $rules ) ) { + $rules = (array) $rules; + } elseif ( ! is_array( $rules ) ) { + $rules = []; + } + $has_registration = ! empty( $rules['registration']['active'] ); $has_access_rules = ! empty( $rules['custom_access']['active'] ) && ! empty( $rules['custom_access']['access_rules'] ); diff --git a/src/content-gate/editor/block-visibility.tsx b/src/content-gate/editor/block-visibility.tsx index e6be0223f3..db9f76822d 100644 --- a/src/content-gate/editor/block-visibility.tsx +++ b/src/content-gate/editor/block-visibility.tsx @@ -148,7 +148,8 @@ const AccessRuleValueControl = ( { return ( o.label ) } onChange={ ( tokens: ( string | { value: string } )[] ) => { From f7f2f1523bdf78f552039a6ed19c3a4da91bb4aa Mon Sep 17 00:00:00 2001 From: dkoo Date: Wed, 8 Apr 2026 14:13:59 -0600 Subject: [PATCH 22/34] fix: allow editors to bypass access requirements --- includes/content-gate/class-block-visibility.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/includes/content-gate/class-block-visibility.php b/includes/content-gate/class-block-visibility.php index 9fca527ba9..8c74a853ee 100644 --- a/includes/content-gate/class-block-visibility.php +++ b/includes/content-gate/class-block-visibility.php @@ -63,8 +63,14 @@ public static function filter_render_block( $block_content, $block ) { return $block_content; } + // Don't restrict content for users who can edit the post it's in. + $post_id = get_the_ID(); + $user_id = get_current_user_id(); + if ( ! empty( $post_id ) && user_can( $user_id, 'edit_post', $post_id ) ) { + return $block_content; + } + $visibility = $block['attrs']['newspackAccessControlVisibility'] ?? 'visible'; - $user_id = get_current_user_id(); $user_matches = self::evaluate_rules_for_user( $rules, $user_id ); if ( 'visible' === $visibility ) { From ec7005b217e1e5e9f3b5345a41cdb354c6b1a563 Mon Sep 17 00:00:00 2001 From: dkoo Date: Wed, 8 Apr 2026 14:18:45 -0600 Subject: [PATCH 23/34] test(content-gate): add editor front-end bypass and restrict tests Cover the case where a user with edit_post capability on the current post sees all blocks regardless of access rules, and verify that a non-editor is still subject to normal rule evaluation. Co-Authored-By: Claude Sonnet 4.6 --- .../content-gate/class-block-visibility.php | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/unit-tests/content-gate/class-block-visibility.php b/tests/unit-tests/content-gate/class-block-visibility.php index 47aeb503db..d59e2614ed 100644 --- a/tests/unit-tests/content-gate/class-block-visibility.php +++ b/tests/unit-tests/content-gate/class-block-visibility.php @@ -379,6 +379,45 @@ public function test_missing_visibility_attribute_defaults_to_visible() { $this->assertSame( '', $result ); } + /** + * A user who can edit the post sees restricted blocks on the front end. + */ + public function test_editor_bypasses_access_rules_on_front_end() { + $editor_id = $this->factory->user->create( [ 'role' => 'editor' ] ); + $post_id = $this->factory->post->create(); + $GLOBALS['post'] = get_post( $post_id ); + + wp_set_current_user( $editor_id ); + Block_Visibility::reset_cache_for_tests(); + + $rules = [ 'registration' => [ 'active' => true ] ]; + $block = $this->make_block_with_rules( 'core/group', $rules, 'visible' ); + $result = Block_Visibility::filter_render_block( '
restricted
', $block ); + + $this->assertSame( '
restricted
', $result ); + + unset( $GLOBALS['post'] ); + } + + /** + * A user who cannot edit the post is still subject to access rules. + */ + public function test_non_editor_still_restricted_on_front_end() { + $post_id = $this->factory->post->create(); + $GLOBALS['post'] = get_post( $post_id ); + + wp_set_current_user( 0 ); + Block_Visibility::reset_cache_for_tests(); + + $rules = [ 'registration' => [ 'active' => true ] ]; + $block = $this->make_block_with_rules( 'core/group', $rules, 'visible' ); + $result = Block_Visibility::filter_render_block( '
restricted
', $block ); + + $this->assertSame( '', $result ); + + unset( $GLOBALS['post'] ); + } + /** * Core/group block has both visibility attributes registered server-side. */ From f3dccb57780bc34ae0573f050d7e76be36da3c0e Mon Sep 17 00:00:00 2001 From: dkoo Date: Wed, 8 Apr 2026 14:39:14 -0600 Subject: [PATCH 24/34] refactor: allow the blocks that can get access rules to be filtered --- includes/content-gate/class-block-visibility.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/includes/content-gate/class-block-visibility.php b/includes/content-gate/class-block-visibility.php index 8c74a853ee..e0eb7375f5 100644 --- a/includes/content-gate/class-block-visibility.php +++ b/includes/content-gate/class-block-visibility.php @@ -33,7 +33,13 @@ public static function init() { * @return string */ public static function filter_render_block( $block_content, $block ) { - $target_blocks = [ 'core/group', 'core/stack', 'core/row' ]; + /** + * Filters the list of blocks that are subject to content gate access control. + * + * @param array $target_blocks List of block names. + * @return array + */ + $target_blocks = apply_filters( 'newspack_content_gate_block_visibility_blocks', [ 'core/group', 'core/stack', 'core/row' ] ); if ( ! in_array( $block['blockName'] ?? '', $target_blocks, true ) ) { return $block_content; } From e822588637f91a91ea6444e111c1303a54bf1f1b Mon Sep 17 00:00:00 2001 From: dkoo Date: Wed, 8 Apr 2026 14:39:53 -0600 Subject: [PATCH 25/34] docs: update docblock --- includes/content-gate/class-block-visibility.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/content-gate/class-block-visibility.php b/includes/content-gate/class-block-visibility.php index e0eb7375f5..0187b5f3bf 100644 --- a/includes/content-gate/class-block-visibility.php +++ b/includes/content-gate/class-block-visibility.php @@ -34,7 +34,7 @@ public static function init() { */ public static function filter_render_block( $block_content, $block ) { /** - * Filters the list of blocks that are subject to content gate access control. + * Filters the list of blocks that can be configured for access control visibility. * * @param array $target_blocks List of block names. * @return array From 2b50ec880201162fe7f62eb66c6bde93d8eb8934 Mon Sep 17 00:00:00 2001 From: dkoo Date: Wed, 8 Apr 2026 14:42:50 -0600 Subject: [PATCH 26/34] refactor: apply filter to all instances of $target_blocks --- .../content-gate/class-block-visibility.php | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/includes/content-gate/class-block-visibility.php b/includes/content-gate/class-block-visibility.php index 0187b5f3bf..31a865e223 100644 --- a/includes/content-gate/class-block-visibility.php +++ b/includes/content-gate/class-block-visibility.php @@ -26,13 +26,11 @@ public static function init() { } /** - * Filter rendered block output based on access control attributes. + * Get the list of blocks that can be configured for access control visibility. * - * @param string $block_content Rendered block HTML. - * @param array $block Block data. - * @return string + * @return array */ - public static function filter_render_block( $block_content, $block ) { + private static function get_target_blocks() { /** * Filters the list of blocks that can be configured for access control visibility. * @@ -40,6 +38,18 @@ public static function filter_render_block( $block_content, $block ) { * @return array */ $target_blocks = apply_filters( 'newspack_content_gate_block_visibility_blocks', [ 'core/group', 'core/stack', 'core/row' ] ); + return $target_blocks; + } + + /** + * Filter rendered block output based on access control attributes. + * + * @param string $block_content Rendered block HTML. + * @param array $block Block data. + * @return string + */ + public static function filter_render_block( $block_content, $block ) { + $target_blocks = self::get_target_blocks(); if ( ! in_array( $block['blockName'] ?? '', $target_blocks, true ) ) { return $block_content; } @@ -94,7 +104,7 @@ public static function filter_render_block( $block_content, $block ) { * @return array */ public static function register_block_type_args( $args, $block_type ) { - $target_blocks = [ 'core/group', 'core/stack', 'core/row' ]; + $target_blocks = self::get_target_blocks(); if ( ! in_array( $block_type, $target_blocks, true ) ) { return $args; } From ff4ec4da6e6e28db43ec61d2c9b162e0374ee2c9 Mon Sep 17 00:00:00 2001 From: dkoo Date: Wed, 8 Apr 2026 15:17:00 -0600 Subject: [PATCH 27/34] feat(content-gate): add gate mode to per-block access control Blocks can now be linked to one or more existing content gates instead of defining access rules inline. Gate rules are resolved server-side at render time so any change to a gate is immediately reflected in every block that references it. - Add newspackAccessControlMode attribute ('gate' default, 'custom') - Add newspackAccessControlGateIds array attribute for gate links - Gate mode uses OR logic: reader must satisfy any one selected gate - Deleted/unpublished gates are silently skipped; a block with only inactive gates passes through with no restriction - Localize available published gates to newspackBlockVisibility JS data - Add GateControls FormTokenField component to the inspector panel - Add mode toggle (Gate / Custom) with Gate as the default - Add 7 new PHPUnit tests covering gate mode evaluation paths Co-Authored-By: Claude Sonnet 4.6 --- .../content-gate/class-block-visibility.php | 136 ++++++++++--- src/content-gate/editor/block-visibility.tsx | 95 ++++++++- src/content-gate/editor/index.d.ts | 7 + .../content-gate/class-block-visibility.php | 190 ++++++++++++++++++ 4 files changed, 395 insertions(+), 33 deletions(-) diff --git a/includes/content-gate/class-block-visibility.php b/includes/content-gate/class-block-visibility.php index 31a865e223..85c650fa58 100644 --- a/includes/content-gate/class-block-visibility.php +++ b/includes/content-gate/class-block-visibility.php @@ -49,8 +49,7 @@ private static function get_target_blocks() { * @return string */ public static function filter_render_block( $block_content, $block ) { - $target_blocks = self::get_target_blocks(); - if ( ! in_array( $block['blockName'] ?? '', $target_blocks, true ) ) { + if ( ! in_array( $block['blockName'] ?? '', self::get_target_blocks(), true ) ) { return $block_content; } @@ -61,22 +60,33 @@ public static function filter_render_block( $block_content, $block ) { return $block_content; } - $rules = $block['attrs']['newspackAccessControlRules'] ?? []; + $mode = $block['attrs']['newspackAccessControlMode'] ?? 'gate'; + $visibility = $block['attrs']['newspackAccessControlVisibility'] ?? 'visible'; - // Defensive cast: the block parser can occasionally yield a stdClass for - // object-typed attributes (e.g. after JSON round-trips). - if ( is_object( $rules ) ) { - $rules = (array) $rules; - } elseif ( ! is_array( $rules ) ) { - $rules = []; - } + if ( 'gate' === $mode ) { + $gate_ids = array_filter( array_map( 'intval', $block['attrs']['newspackAccessControlGateIds'] ?? [] ) ); + if ( empty( $gate_ids ) ) { + return $block_content; // No gates selected → pass-through. + } + } else { + // Custom mode: check whether any rules are active before going further. + $rules = $block['attrs']['newspackAccessControlRules'] ?? []; - $has_registration = ! empty( $rules['registration']['active'] ); - $has_access_rules = ! empty( $rules['custom_access']['active'] ) - && ! empty( $rules['custom_access']['access_rules'] ); + // Defensive cast: the block parser can occasionally yield a stdClass for + // object-typed attributes (e.g. after JSON round-trips). + if ( is_object( $rules ) ) { + $rules = (array) $rules; + } elseif ( ! is_array( $rules ) ) { + $rules = []; + } - if ( ! $has_registration && ! $has_access_rules ) { - return $block_content; + $has_registration = ! empty( $rules['registration']['active'] ); + $has_access_rules = ! empty( $rules['custom_access']['active'] ) + && ! empty( $rules['custom_access']['access_rules'] ); + + if ( ! $has_registration && ! $has_access_rules ) { + return $block_content; // No active rules → pass-through. + } } // Don't restrict content for users who can edit the post it's in. @@ -86,8 +96,9 @@ public static function filter_render_block( $block_content, $block ) { return $block_content; } - $visibility = $block['attrs']['newspackAccessControlVisibility'] ?? 'visible'; - $user_matches = self::evaluate_rules_for_user( $rules, $user_id ); + $user_matches = ( 'gate' === $mode ) + ? self::evaluate_gate_rules_for_user( $gate_ids, $user_id ) + : self::evaluate_rules_for_user( $rules, $user_id ); if ( 'visible' === $visibility ) { return $user_matches ? $block_content : ''; @@ -97,15 +108,14 @@ public static function filter_render_block( $block_content, $block ) { } /** - * Register block attributes server-side for the three target block types. + * Register block attributes server-side for target block types. * * @param array $args Block type arguments. * @param string $block_type Block type name. * @return array */ public static function register_block_type_args( $args, $block_type ) { - $target_blocks = self::get_target_blocks(); - if ( ! in_array( $block_type, $target_blocks, true ) ) { + if ( ! in_array( $block_type, self::get_target_blocks(), true ) ) { return $args; } @@ -116,6 +126,17 @@ public static function register_block_type_args( $args, $block_type ) { 'type' => 'string', 'default' => 'visible', ], + 'newspackAccessControlMode' => [ + 'type' => 'string', + 'default' => 'gate', + ], + 'newspackAccessControlGateIds' => [ + 'type' => 'array', + 'default' => [], + 'items' => [ + 'type' => 'integer', + ], + ], 'newspackAccessControlRules' => [ 'type' => 'object', 'default' => [], @@ -169,12 +190,23 @@ function( $rule ) { }, Access_Rules::get_access_rules() ), + 'available_gates' => array_values( + array_map( + function( $gate ) { + return [ + 'id' => $gate['id'], + 'title' => $gate['title'], + ]; + }, + Content_Gate::get_gates( Content_Gate::GATE_CPT, 'publish' ) + ) + ), ] ); } /** - * Per-request cache: keyed by "{user_id}:{md5(rules)}". + * Per-request cache: keyed by "{user_id}:{md5(rules)}" or "gate:{user_id}:{md5(gate_ids)}". * * @var bool[] */ @@ -199,7 +231,7 @@ public static function evaluate_rules_for_user_public( $rules, $user_id ) { } /** - * Evaluate whether a user matches the block's access rules. + * Evaluate whether a user matches the block's custom access rules (with caching). * * @param array $rules Parsed newspackAccessControlRules attribute. * @param int $user_id User ID (0 for logged-out). @@ -211,11 +243,69 @@ private static function evaluate_rules_for_user( $rules, $user_id ) { return self::$rules_match_cache[ $cache_key ]; } - $result = self::compute_rules_match( $rules, $user_id ); + $result = self::compute_rules_match( $rules, $user_id ); self::$rules_match_cache[ $cache_key ] = $result; return $result; } + /** + * Evaluate whether a user matches any of the given gate's access rules (with caching). + * + * Deleted or unpublished gates are silently skipped. If every gate in the list + * is deleted/unpublished the result is true (pass-through — no active restriction). + * + * @param int[] $gate_ids Array of np_content_gate post IDs. + * @param int $user_id User ID (0 for logged-out). + * @return bool + */ + private static function evaluate_gate_rules_for_user( $gate_ids, $user_id ) { + $cache_key = 'gate:' . $user_id . ':' . md5( wp_json_encode( $gate_ids ) ); + if ( isset( self::$rules_match_cache[ $cache_key ] ) ) { + return self::$rules_match_cache[ $cache_key ]; + } + + $result = self::compute_gate_rules_match( $gate_ids, $user_id ); + self::$rules_match_cache[ $cache_key ] = $result; + return $result; + } + + /** + * Compute whether a user matches the access rules of any of the given gates (uncached). + * + * @param int[] $gate_ids Array of np_content_gate post IDs. + * @param int $user_id User ID (0 for logged-out). + * @return bool + */ + private static function compute_gate_rules_match( $gate_ids, $user_id ) { + $has_active_gate = false; + + foreach ( $gate_ids as $gate_id ) { + $gate = Content_Gate::get_gate( $gate_id ); + + // Deleted gate: Content_Gate::get_gate() returns WP_Error when the post + // doesn't exist. Unpublished gates have status !== 'publish'. Both are + // skipped so only currently-active gates impose restrictions. + if ( \is_wp_error( $gate ) || 'publish' !== $gate['status'] ) { + continue; + } + + $has_active_gate = true; + + $rules = [ + 'registration' => $gate['registration'], + 'custom_access' => $gate['custom_access'], + ]; + + // OR logic: the user passes if they satisfy any single active gate's rules. + if ( self::compute_rules_match( $rules, $user_id ) ) { + return true; + } + } + + // All gates were deleted or unpublished → no active restriction → pass-through. + return ! $has_active_gate; + } + /** * Compute whether a user matches the block's access rules (uncached). * diff --git a/src/content-gate/editor/block-visibility.tsx b/src/content-gate/editor/block-visibility.tsx index db9f76822d..6331801efa 100644 --- a/src/content-gate/editor/block-visibility.tsx +++ b/src/content-gate/editor/block-visibility.tsx @@ -45,6 +45,15 @@ addFilter( 'blocks.registerBlockType', 'newspack-plugin/block-visibility/attribu type: 'string', default: 'visible', }, + newspackAccessControlMode: { + type: 'string', + default: 'gate', + }, + newspackAccessControlGateIds: { + type: 'array', + default: [], + items: { type: 'integer' }, + }, newspackAccessControlRules: { type: 'object', default: {}, @@ -58,10 +67,18 @@ addFilter( 'blocks.registerBlockType', 'newspack-plugin/block-visibility/attribu */ const availableAccessRules: Record< string, AccessRuleConfig > = window.newspackBlockVisibility?.available_access_rules ?? {}; +/** + * Available gates from localized data. + */ +const availableGates: GateOption[] = window.newspackBlockVisibility?.available_gates ?? []; + /** * Whether any rules are currently active on the block. */ -function hasActiveRules( rules: BlockVisibilityRules ): boolean { +function hasActiveRules( rules: BlockVisibilityRules, mode: string, gateIds: number[] ): boolean { + if ( 'gate' === mode ) { + return gateIds.length > 0; + } return !! rules?.registration?.active || !! rules?.custom_access?.active; } @@ -94,6 +111,31 @@ const VisibilityControl = ( {
); +/** + * Gate selector: a FormTokenField that lets the editor link one or more gates. + * A reader needs to satisfy any one of the selected gates' rules to match. + */ +const GateControls = ( { gateIds, onChange }: { gateIds: number[]; onChange: ( ids: number[] ) => void } ) => { + const selectedLabels = availableGates.filter( g => gateIds.includes( g.id ) ).map( g => g.title ); + + return ( + + g.title ) } + onChange={ ( tokens: ( string | { value: string } )[] ) => { + const labels = tokens.map( t => ( typeof t === 'string' ? t : t.value ) ); + onChange( availableGates.filter( g => labels.includes( g.title ) ).map( g => g.id ) ); + } } + __experimentalExpandOnFocus + __next40pxDefaultSize + /> + + ); +}; + /** * Rules whose options must be fetched dynamically. */ @@ -253,20 +295,22 @@ const RegistrationControls = ( { const BlockVisibilityPanel = ( { attributes, setAttributes }: BlockEditProps ) => { const rules: BlockVisibilityRules = attributes.newspackAccessControlRules ?? {}; const visibility: string = attributes.newspackAccessControlVisibility ?? 'visible'; + const mode: string = attributes.newspackAccessControlMode ?? 'gate'; + const gateIds: number[] = attributes.newspackAccessControlGateIds ?? []; const registration: RegistrationRule = rules.registration ?? { active: false }; const customAccess: CustomAccessRule = rules.custom_access ?? { active: false, access_rules: [] }; // Flatten grouped OR rules for display: [[rule]] → [rule] const activeRules: ActiveRule[] = customAccess.access_rules.map( group => group[ 0 ] ).filter( Boolean ); - const rulesActive = hasActiveRules( rules ); + const rulesActive = hasActiveRules( rules, mode, gateIds ); const updateRules = ( updates: Partial< BlockVisibilityRules > ) => { const newRules: BlockVisibilityRules = { ...rules, ...updates }; - const stillActive = hasActiveRules( newRules ); + const stillActive = hasActiveRules( newRules, mode, gateIds ); setAttributes( { newspackAccessControlRules: newRules, - // Reset visibility to 'visible' when all rules are cleared. + // Reset visibility to 'visible' when all custom rules are cleared. ...( ! stillActive ? { newspackAccessControlVisibility: 'visible' } : {} ), } ); }; @@ -299,6 +343,43 @@ const BlockVisibilityPanel = ( { attributes, setAttributes }: BlockEditProps ) = title={ __( 'Access Control', 'newspack-plugin' ) } initialOpen={ rulesActive } > + { /* Mode toggle: Gate (default) or Custom */ } + + setAttributes( { newspackAccessControlMode: String( v ?? 'gate' ) } ) } + isBlock + __next40pxDefaultSize + > + + + + + + { 'gate' === mode && ( + { + setAttributes( { + newspackAccessControlGateIds: ids, + // Reset visibility when the last gate is removed. + ...( ids.length === 0 ? { newspackAccessControlVisibility: 'visible' } : {} ), + } ); + } } + /> + ) } + + { 'custom' === mode && ( + <> + { /* Registration toggle */ } + + + { /* Access rule toggles */ } + + + ) } + setAttributes( { newspackAccessControlVisibility: v } ) } disabled={ ! rulesActive } /> - - { /* Registration toggle */ } - - - { /* Access rule toggles */ } - ); diff --git a/src/content-gate/editor/index.d.ts b/src/content-gate/editor/index.d.ts index 4bdad1a93b..16220969fa 100644 --- a/src/content-gate/editor/index.d.ts +++ b/src/content-gate/editor/index.d.ts @@ -41,9 +41,15 @@ type BlockVisibilityRules = { registration?: RegistrationRule; custom_access?: CustomAccessRule; }; +type GateOption = { + id: number; + title: string; +}; type BlockVisibilityAttributes = { newspackAccessControlRules: BlockVisibilityRules; newspackAccessControlVisibility: string; + newspackAccessControlMode: string; + newspackAccessControlGateIds: number[]; [ key: string ]: unknown; }; type BlockEditProps = { @@ -56,5 +62,6 @@ type BlockEditProps = { interface Window { newspackBlockVisibility: { available_access_rules: Record< string, AccessRuleConfig >; + available_gates: GateOption[]; }; } diff --git a/tests/unit-tests/content-gate/class-block-visibility.php b/tests/unit-tests/content-gate/class-block-visibility.php index d59e2614ed..e463fe0b84 100644 --- a/tests/unit-tests/content-gate/class-block-visibility.php +++ b/tests/unit-tests/content-gate/class-block-visibility.php @@ -292,6 +292,7 @@ private function make_block_with_rules( $block_name, $rules, $visibility = 'visi return $this->make_block( $block_name, [ + 'newspackAccessControlMode' => 'custom', 'newspackAccessControlRules' => $rules, 'newspackAccessControlVisibility' => $visibility, ] @@ -370,6 +371,7 @@ public function test_missing_visibility_attribute_defaults_to_visible() { $block = $this->make_block( 'core/group', [ + 'newspackAccessControlMode' => 'custom', 'newspackAccessControlRules' => [ 'registration' => [ 'active' => true ] ], // newspackAccessControlVisibility intentionally omitted. ] @@ -461,4 +463,192 @@ public function test_result_is_cached() { // Callback fired only once despite two calls with identical rules + user. $this->assertSame( 1, $call_count ); } + + // ----------------------------------------------------------------------- + // Gate mode tests + // ----------------------------------------------------------------------- + + /** + * Helper: create a published gate post and optionally set its registration meta. + * + * @param bool $registration_active Whether to activate the registration rule. + * @param string $status Post status. Default 'publish'. + * @return int Gate post ID. + */ + private function make_gate( $registration_active = true, $status = 'publish' ) { + $gate_id = $this->factory->post->create( + [ + 'post_type' => \Newspack\Content_Gate::GATE_CPT, + 'post_status' => $status, + ] + ); + if ( $registration_active ) { + update_post_meta( $gate_id, 'registration', [ 'active' => true ] ); + } + return $gate_id; + } + + /** + * Gate mode with no gates selected passes through regardless of user. + */ + public function test_gate_mode_no_gates_passes_through() { + wp_set_current_user( 0 ); + Block_Visibility::reset_cache_for_tests(); + $block = $this->make_block( + 'core/group', + [ + 'newspackAccessControlMode' => 'gate', + 'newspackAccessControlGateIds' => [], + ] + ); + $result = Block_Visibility::filter_render_block( '
x
', $block ); + $this->assertSame( '
x
', $result ); + } + + /** + * Gate mode: user matching an active gate's rules sees the block. + */ + public function test_gate_mode_matching_user_sees_block() { + $gate_id = $this->make_gate(); + + wp_set_current_user( $this->test_user_id ); + Block_Visibility::reset_cache_for_tests(); + + $block = $this->make_block( + 'core/group', + [ + 'newspackAccessControlMode' => 'gate', + 'newspackAccessControlGateIds' => [ $gate_id ], + ] + ); + $result = Block_Visibility::filter_render_block( '
members
', $block ); + $this->assertSame( '
members
', $result ); + } + + /** + * Gate mode: user not matching an active gate's rules does not see the block. + */ + public function test_gate_mode_non_matching_user_hidden() { + $gate_id = $this->make_gate(); + + wp_set_current_user( 0 ); + Block_Visibility::reset_cache_for_tests(); + + $block = $this->make_block( + 'core/group', + [ + 'newspackAccessControlMode' => 'gate', + 'newspackAccessControlGateIds' => [ $gate_id ], + ] + ); + $result = Block_Visibility::filter_render_block( '
members
', $block ); + $this->assertSame( '', $result ); + } + + /** + * Gate mode: an unpublished (draft) gate is skipped — results in pass-through. + */ + public function test_gate_mode_unpublished_gate_passes_through() { + $gate_id = $this->make_gate( true, 'draft' ); + + wp_set_current_user( 0 ); + Block_Visibility::reset_cache_for_tests(); + + $block = $this->make_block( + 'core/group', + [ + 'newspackAccessControlMode' => 'gate', + 'newspackAccessControlGateIds' => [ $gate_id ], + ] + ); + $result = Block_Visibility::filter_render_block( '
x
', $block ); + $this->assertSame( '
x
', $result ); + } + + /** + * Gate mode: a permanently deleted gate is skipped — results in pass-through. + */ + public function test_gate_mode_deleted_gate_passes_through() { + $gate_id = $this->make_gate(); + wp_delete_post( $gate_id, true ); // Force-delete. + + wp_set_current_user( 0 ); + Block_Visibility::reset_cache_for_tests(); + + $block = $this->make_block( + 'core/group', + [ + 'newspackAccessControlMode' => 'gate', + 'newspackAccessControlGateIds' => [ $gate_id ], + ] + ); + $result = Block_Visibility::filter_render_block( '
x
', $block ); + $this->assertSame( '
x
', $result ); + } + + /** + * Gate mode: a deleted gate alongside an active gate; only the active gate is evaluated. + */ + public function test_gate_mode_deleted_gate_does_not_affect_active_gate() { + $active_gate_id = $this->make_gate(); + $deleted_gate_id = $this->make_gate(); + wp_delete_post( $deleted_gate_id, true ); + + // Logged-out user does not satisfy the active gate's registration rule. + wp_set_current_user( 0 ); + Block_Visibility::reset_cache_for_tests(); + + $block = $this->make_block( + 'core/group', + [ + 'newspackAccessControlMode' => 'gate', + 'newspackAccessControlGateIds' => [ $active_gate_id, $deleted_gate_id ], + ] + ); + $result = Block_Visibility::filter_render_block( '
x
', $block ); + $this->assertSame( '', $result ); + } + + /** + * Gate mode: OR logic — user matching any one of multiple active gates sees the block. + */ + public function test_gate_mode_or_logic_any_matching_gate_passes() { + // Gate A: requires custom access rule that only matches test_user_id. + $gate_a = $this->make_gate( false ); // No registration rule. + update_post_meta( + $gate_a, + 'custom_access', + [ + 'active' => true, + 'access_rules' => [ + [ + [ + 'slug' => 'test_rule', + 'value' => $this->test_user_id, + ], + ], + ], + ] + ); + + // Gate B: requires registration (logged-in only). + $gate_b = $this->make_gate( true ); + + // A logged-out user matches neither gate. + wp_set_current_user( 0 ); + Block_Visibility::reset_cache_for_tests(); + $block = $this->make_block( + 'core/group', + [ + 'newspackAccessControlMode' => 'gate', + 'newspackAccessControlGateIds' => [ $gate_a, $gate_b ], + ] + ); + $this->assertSame( '', Block_Visibility::filter_render_block( '
x
', $block ) ); + + // The test user matches Gate A (custom rule), so they see the block. + wp_set_current_user( $this->test_user_id ); + Block_Visibility::reset_cache_for_tests(); + $this->assertSame( '
x
', Block_Visibility::filter_render_block( '
x
', $block ) ); + } } From a0ee1ea974a0702f9f8a6222758bfdcd6d29ad17 Mon Sep 17 00:00:00 2001 From: dkoo Date: Wed, 8 Apr 2026 15:40:29 -0600 Subject: [PATCH 28/34] style: tweak UI in Access Control panel --- src/content-gate/editor/block-visibility.tsx | 22 ++++++++++++-------- src/content-gate/editor/editor.scss | 4 ++++ 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/content-gate/editor/block-visibility.tsx b/src/content-gate/editor/block-visibility.tsx index 6331801efa..40fef862b1 100644 --- a/src/content-gate/editor/block-visibility.tsx +++ b/src/content-gate/editor/block-visibility.tsx @@ -18,7 +18,7 @@ import { } from '@wordpress/components'; import { useState, useEffect } from '@wordpress/element'; import apiFetch from '@wordpress/api-fetch'; -import { __ } from '@wordpress/i18n'; +import { __, sprintf } from '@wordpress/i18n'; /** * Internal dependencies @@ -105,8 +105,8 @@ const VisibilityControl = ( { isBlock __next40pxDefaultSize > - - + + ); @@ -122,7 +122,6 @@ const GateControls = ( { gateIds, onChange }: { gateIds: number[]; onChange: ( i g.title ) } onChange={ ( tokens: ( string | { value: string } )[] ) => { @@ -190,7 +189,6 @@ const AccessRuleValueControl = ( { return ( o.label ) } @@ -340,13 +338,15 @@ const BlockVisibilityPanel = ( { attributes, setAttributes }: BlockEditProps ) = +

{ __( 'Control visibility of this block using gates or custom rules.', 'newspack-plugin' ) }

+ { /* Mode toggle: Gate (default) or Custom */ } setAttributes( { newspackAccessControlMode: String( v ?? 'gate' ) } ) } isBlock @@ -381,8 +381,12 @@ const BlockVisibilityPanel = ( { attributes, setAttributes }: BlockEditProps ) = ) } setAttributes( { newspackAccessControlVisibility: v } ) } disabled={ ! rulesActive } diff --git a/src/content-gate/editor/editor.scss b/src/content-gate/editor/editor.scss index 2784ea4576..d92168c968 100644 --- a/src/content-gate/editor/editor.scss +++ b/src/content-gate/editor/editor.scss @@ -61,6 +61,10 @@ * Access control block visibility panel. */ .newspack-access-control-block-visibility-panel { + .components-base-control, + .components-form-token-field { + width: 100%; + } .components-toggle-control { margin-top: 8px; width: 100%; From 52926321b30b86d3f4ae039b370a1ae9d6b47064 Mon Sep 17 00:00:00 2001 From: Thomas Guillot Date: Tue, 14 Apr 2026 12:49:42 +0100 Subject: [PATCH 29/34] style: replace hardcoded px with WP grid-unit tokens --- src/content-gate/editor/editor.scss | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/content-gate/editor/editor.scss b/src/content-gate/editor/editor.scss index d92168c968..978aad8336 100644 --- a/src/content-gate/editor/editor.scss +++ b/src/content-gate/editor/editor.scss @@ -1,4 +1,5 @@ @use "~@wordpress/base-styles/colors" as wp-colors; +@use "~@wordpress/base-styles/variables" as wp-vars; @use "../vars" as gate; .edit-post-post-visibility { @@ -66,10 +67,10 @@ width: 100%; } .components-toggle-control { - margin-top: 8px; + margin-top: wp-vars.$grid-unit-10; width: 100%; .components-base-control__field { - margin-bottom: 4px; + margin-bottom: wp-vars.$grid-unit-05; } .components-form-toggle { order: 2; From e844d3a514b43ba40ca5eb9c0fe63cb152f173c9 Mon Sep 17 00:00:00 2001 From: Thomas Guillot Date: Tue, 14 Apr 2026 12:52:17 +0100 Subject: [PATCH 30/34] style: keep default ToggleControl order in Access Control panel --- src/content-gate/editor/editor.scss | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/content-gate/editor/editor.scss b/src/content-gate/editor/editor.scss index 978aad8336..4e20e1f36f 100644 --- a/src/content-gate/editor/editor.scss +++ b/src/content-gate/editor/editor.scss @@ -72,14 +72,8 @@ .components-base-control__field { margin-bottom: wp-vars.$grid-unit-05; } - .components-form-toggle { - order: 2; - } .components-base-control__help { margin-top: 0; } - .components-toggle-control__help { - margin-inline-start: auto; - } } } From 04640ad15d1e31831c976803ccb5c0af3663c2b2 Mon Sep 17 00:00:00 2001 From: Thomas Guillot Date: Tue, 14 Apr 2026 13:30:42 +0100 Subject: [PATCH 31/34] refactor(content-gate): tighten Access Control panel layout - Always render Require verification toggle (disabled when parent off) to avoid layout jump - Split registration section into sibling PanelRows - Swap verification CheckboxControl for ToggleControl - Move intro copy onto the Access mode ToggleGroupControl help prop - Opt all inputs in the panel into __nextHasNoMarginBottom - Restore FormTokenField help margin-top stripped by Gutenberg's inspector p-reset --- src/content-gate/editor/block-visibility.tsx | 36 +++++++++++--------- src/content-gate/editor/editor.scss | 6 ++++ 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/src/content-gate/editor/block-visibility.tsx b/src/content-gate/editor/block-visibility.tsx index 40fef862b1..d2fc2f3ea2 100644 --- a/src/content-gate/editor/block-visibility.tsx +++ b/src/content-gate/editor/block-visibility.tsx @@ -5,7 +5,6 @@ import { addFilter } from '@wordpress/hooks'; import { createHigherOrderComponent } from '@wordpress/compose'; import { InspectorControls } from '@wordpress/block-editor'; import { - CheckboxControl, FormTokenField, PanelBody, PanelRow, @@ -104,6 +103,7 @@ const VisibilityControl = ( { onChange={ v => onChange( String( v ?? 'visible' ) ) } isBlock __next40pxDefaultSize + __nextHasNoMarginBottom > @@ -130,6 +130,7 @@ const GateControls = ( { gateIds, onChange }: { gateIds: number[]; onChange: ( i } } __experimentalExpandOnFocus __next40pxDefaultSize + __nextHasNoMarginBottom /> ); @@ -198,6 +199,7 @@ const AccessRuleValueControl = ( { } } __experimentalExpandOnFocus __next40pxDefaultSize + __nextHasNoMarginBottom /> ); } @@ -211,6 +213,7 @@ const AccessRuleValueControl = ( { value={ typeof value === 'string' ? value : '' } onChange={ onChange as ( value: string ) => void } __next40pxDefaultSize + __nextHasNoMarginBottom /> ); }; @@ -259,7 +262,7 @@ const AccessRulesControls = ( { activeRules, onChange }: { activeRules: ActiveRu ); }; -/** Registration section: logged-in toggle + optional verification sub-toggle. */ +/** Registration section: logged-in toggle + verification sub-toggle. */ const RegistrationControls = ( { registration, onChange, @@ -267,24 +270,25 @@ const RegistrationControls = ( { registration: RegistrationRule; onChange: ( registration: RegistrationRule ) => void; } ) => ( - -
+ <> + onChange( { ...registration, active } ) } /> - { registration.active && ( - onChange( { ...registration, require_verification } ) } - /> - ) } -
-
+
+ + onChange( { ...registration, require_verification } ) } + /> + + ); /** @@ -341,16 +345,16 @@ const BlockVisibilityPanel = ( { attributes, setAttributes }: BlockEditProps ) = title={ __( 'Access control', 'newspack-plugin' ) } initialOpen={ rulesActive } > -

{ __( 'Control visibility of this block using gates or custom rules.', 'newspack-plugin' ) }

- { /* Mode toggle: Gate (default) or Custom */ } setAttributes( { newspackAccessControlMode: String( v ?? 'gate' ) } ) } isBlock __next40pxDefaultSize + __nextHasNoMarginBottom > diff --git a/src/content-gate/editor/editor.scss b/src/content-gate/editor/editor.scss index 4e20e1f36f..cea496a61a 100644 --- a/src/content-gate/editor/editor.scss +++ b/src/content-gate/editor/editor.scss @@ -66,6 +66,12 @@ .components-form-token-field { width: 100%; } + // Gutenberg's `.block-editor-block-inspector p:not(.components-base-control__help)` rule + // strips the emotion margin-top on FormTokenField's help text. Nested here to bump + // specificity above Gutenberg's and restore the gap. + .components-form-token-field .components-form-token-field__help { + margin-top: wp-vars.$grid-unit-10; + } .components-toggle-control { margin-top: wp-vars.$grid-unit-10; width: 100%; From 3d3d276366d44a6a4263fec67ca9abddeede3ee8 Mon Sep 17 00:00:00 2001 From: dkoo Date: Thu, 16 Apr 2026 11:33:48 -0600 Subject: [PATCH 32/34] fix(content-gate): address human review feedback on block visibility - Sync TARGET_BLOCKS with PHP: pass get_target_blocks() result via wp_localize_script so the JS honours the newspack_content_gate_block_visibility_blocks filter - Fix hidden-mode bug when all gates are inactive: add has_active_gates() guard in filter_render_block() so a block whose gates are all deleted or unpublished passes through regardless of the visibility setting - Use (object) [] as default for newspackAccessControlRules to match the object type declaration and the JS default of {} - Add regression test: deleted gate + hidden mode must show the block Co-Authored-By: Claude Sonnet 4.6 --- .../content-gate/class-block-visibility.php | 34 +++++++++++++++++-- src/content-gate/editor/block-visibility.tsx | 5 ++- src/content-gate/editor/index.d.ts | 1 + .../content-gate/class-block-visibility.php | 27 ++++++++++++++- 4 files changed, 62 insertions(+), 5 deletions(-) diff --git a/includes/content-gate/class-block-visibility.php b/includes/content-gate/class-block-visibility.php index 85c650fa58..2fa36d78c1 100644 --- a/includes/content-gate/class-block-visibility.php +++ b/includes/content-gate/class-block-visibility.php @@ -68,6 +68,13 @@ public static function filter_render_block( $block_content, $block ) { if ( empty( $gate_ids ) ) { return $block_content; // No gates selected → pass-through. } + // If every referenced gate has been deleted or unpublished, treat as + // pass-through regardless of the visibility setting. This mirrors the + // "no gates selected" case and prevents 'hidden' mode from permanently + // hiding the block after a gate is removed. + if ( ! self::has_active_gates( $gate_ids ) ) { + return $block_content; + } } else { // Custom mode: check whether any rules are active before going further. $rules = $block['attrs']['newspackAccessControlRules'] ?? []; @@ -139,7 +146,7 @@ public static function register_block_type_args( $args, $block_type ) { ], 'newspackAccessControlRules' => [ 'type' => 'object', - 'default' => [], + 'default' => (object) [], ], ] ); @@ -183,6 +190,7 @@ public static function enqueue_block_editor_assets() { 'newspack-content-gate-block-visibility', 'newspackBlockVisibility', [ + 'target_blocks' => self::get_target_blocks(), 'available_access_rules' => array_map( function( $rule ) { unset( $rule['callback'] ); @@ -248,11 +256,31 @@ private static function evaluate_rules_for_user( $rules, $user_id ) { return $result; } + /** + * Return true if at least one gate in the list is published and accessible. + * + * Used as an early-exit guard in filter_render_block() so that a block whose + * only gates have all been deleted or unpublished is treated as unrestricted, + * regardless of the block's visibility setting. + * + * @param int[] $gate_ids Array of np_content_gate post IDs. + * @return bool + */ + private static function has_active_gates( $gate_ids ) { + foreach ( $gate_ids as $gate_id ) { + $gate = Content_Gate::get_gate( $gate_id ); + if ( ! \is_wp_error( $gate ) && 'publish' === $gate['status'] ) { + return true; + } + } + return false; + } + /** * Evaluate whether a user matches any of the given gate's access rules (with caching). * - * Deleted or unpublished gates are silently skipped. If every gate in the list - * is deleted/unpublished the result is true (pass-through — no active restriction). + * Assumes at least one gate in $gate_ids is active; call has_active_gates() first + * when a pass-through fallback is needed for fully-inactive gate lists. * * @param int[] $gate_ids Array of np_content_gate post IDs. * @param int $user_id User ID (0 for logged-out). diff --git a/src/content-gate/editor/block-visibility.tsx b/src/content-gate/editor/block-visibility.tsx index d2fc2f3ea2..d23fe1cbf1 100644 --- a/src/content-gate/editor/block-visibility.tsx +++ b/src/content-gate/editor/block-visibility.tsx @@ -26,8 +26,11 @@ import './editor.scss'; /** * Target block types that receive access control attributes. + * Sourced from PHP (respects the newspack_content_gate_block_visibility_blocks filter) + * with the default list as a fallback for environments where the script is loaded + * before localisation runs. */ -const TARGET_BLOCKS = [ 'core/group', 'core/stack', 'core/row' ]; +const TARGET_BLOCKS: string[] = window.newspackBlockVisibility?.target_blocks ?? [ 'core/group', 'core/stack', 'core/row' ]; /** * Register custom attributes on target block types. diff --git a/src/content-gate/editor/index.d.ts b/src/content-gate/editor/index.d.ts index 16220969fa..ef01c886f8 100644 --- a/src/content-gate/editor/index.d.ts +++ b/src/content-gate/editor/index.d.ts @@ -61,6 +61,7 @@ type BlockEditProps = { interface Window { newspackBlockVisibility: { + target_blocks: string[]; available_access_rules: Record< string, AccessRuleConfig >; available_gates: GateOption[]; }; diff --git a/tests/unit-tests/content-gate/class-block-visibility.php b/tests/unit-tests/content-gate/class-block-visibility.php index e463fe0b84..22f799d23c 100644 --- a/tests/unit-tests/content-gate/class-block-visibility.php +++ b/tests/unit-tests/content-gate/class-block-visibility.php @@ -568,7 +568,7 @@ public function test_gate_mode_unpublished_gate_passes_through() { /** * Gate mode: a permanently deleted gate is skipped — results in pass-through. */ - public function test_gate_mode_deleted_gate_passes_through() { + public function test_gate_mode_deleted_gate_passes_through_in_visible_mode() { $gate_id = $this->make_gate(); wp_delete_post( $gate_id, true ); // Force-delete. @@ -586,6 +586,31 @@ public function test_gate_mode_deleted_gate_passes_through() { $this->assertSame( '
x
', $result ); } + /** + * Gate mode: deleted gate in 'hidden' mode still passes through — no gate = no restriction. + * + * Regression: previously $user_matches = true (pass-through sentinel) combined with + * visibility = 'hidden' would hide the block from everyone instead of showing it. + */ + public function test_gate_mode_deleted_gate_passes_through_in_hidden_mode() { + $gate_id = $this->make_gate(); + wp_delete_post( $gate_id, true ); // Force-delete. + + wp_set_current_user( 0 ); + Block_Visibility::reset_cache_for_tests(); + + $block = $this->make_block( + 'core/group', + [ + 'newspackAccessControlMode' => 'gate', + 'newspackAccessControlGateIds' => [ $gate_id ], + 'newspackAccessControlVisibility' => 'hidden', + ] + ); + $result = Block_Visibility::filter_render_block( '
x
', $block ); + $this->assertSame( '
x
', $result ); + } + /** * Gate mode: a deleted gate alongside an active gate; only the active gate is evaluated. */ From fdb124a581f61b5304a72804be17600c8597bb0b Mon Sep 17 00:00:00 2001 From: dkoo Date: Thu, 16 Apr 2026 11:40:58 -0600 Subject: [PATCH 33/34] refactor(content-gate): use TokenItem objects in FormTokenField for ID-based selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch both FormTokenField instances (GateControls and AccessRuleValueControl) from plain title/label strings to { value, title } token objects. The value field carries the item ID so token removal is keyed by ID rather than display string, which means two items with identical labels can coexist as tokens and are removed independently. New tokens added from suggestions still arrive as plain strings and are resolved to IDs by label lookup — an inherent FormTokenField limitation, but removal reliability is the more common concern in practice. Co-Authored-By: Claude Sonnet 4.6 --- src/content-gate/editor/block-visibility.tsx | 46 +++++++++++++++----- src/content-gate/editor/index.d.ts | 8 ++++ 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/src/content-gate/editor/block-visibility.tsx b/src/content-gate/editor/block-visibility.tsx index d23fe1cbf1..2619980f5e 100644 --- a/src/content-gate/editor/block-visibility.tsx +++ b/src/content-gate/editor/block-visibility.tsx @@ -119,17 +119,31 @@ const VisibilityControl = ( { * A reader needs to satisfy any one of the selected gates' rules to match. */ const GateControls = ( { gateIds, onChange }: { gateIds: number[]; onChange: ( ids: number[] ) => void } ) => { - const selectedLabels = availableGates.filter( g => gateIds.includes( g.id ) ).map( g => g.title ); + // Use token objects so removal is keyed by gate ID, not title string. + // This means two gates with the same title can coexist as tokens without + // one removal accidentally removing both. + const selectedTokens: TokenItem[] = availableGates + .filter( g => gateIds.includes( g.id ) ) + .map( g => ( { value: String( g.id ), title: g.title } ) ); return ( g.title ) } - onChange={ ( tokens: ( string | { value: string } )[] ) => { - const labels = tokens.map( t => ( typeof t === 'string' ? t : t.value ) ); - onChange( availableGates.filter( g => labels.includes( g.title ) ).map( g => g.id ) ); + onChange={ ( tokens: ( string | TokenItem )[] ) => { + const newIds = tokens.flatMap( t => { + if ( typeof t !== 'string' ) { + // Existing token — value is the stringified gate ID. + const id = parseInt( t.value, 10 ); + return isNaN( id ) ? [] : [ id ]; + } + // New token from suggestions — look up by title. + const gate = availableGates.find( g => g.title === t ); + return gate ? [ gate.id ] : []; + } ); + onChange( newIds ); } } __experimentalExpandOnFocus __next40pxDefaultSize @@ -187,18 +201,28 @@ const AccessRuleValueControl = ( { }, [ slug ] ); // eslint-disable-line react-hooks/exhaustive-deps if ( options.length > 0 ) { - // Map stored IDs to labels for display; silently drop IDs with no matching option. const valueArr = Array.isArray( value ) ? value : []; - const selectedLabels = options.filter( o => valueArr.some( v => String( v ) === String( o.value ) ) ).map( o => o.label ); + // Use token objects so removal is keyed by option value, not label string. + const selectedTokens: TokenItem[] = options + .filter( o => valueArr.some( v => String( v ) === String( o.value ) ) ) + .map( o => ( { value: String( o.value ), title: o.label } ) ); return ( o.label ) } - onChange={ ( tokens: ( string | { value: string } )[] ) => { - const labels = tokens.map( t => ( typeof t === 'string' ? t : t.value ) ); - onChange( options.filter( o => labels.includes( o.label ) ).map( o => o.value ) ); + onChange={ ( tokens: ( string | TokenItem )[] ) => { + const newValues = tokens.flatMap( t => { + if ( typeof t !== 'string' ) { + // Existing token — value is String(option.value). + return [ t.value ]; + } + // New token from suggestions — look up by label. + const opt = options.find( o => o.label === t ); + return opt ? [ String( opt.value ) ] : []; + } ); + onChange( newValues ); } } __experimentalExpandOnFocus __next40pxDefaultSize diff --git a/src/content-gate/editor/index.d.ts b/src/content-gate/editor/index.d.ts index ef01c886f8..eab6675390 100644 --- a/src/content-gate/editor/index.d.ts +++ b/src/content-gate/editor/index.d.ts @@ -45,6 +45,14 @@ type GateOption = { id: number; title: string; }; +/** + * FormTokenField token object — value is used for identity/removal, + * title is displayed in the chip. Matches the WordPress TokenItem shape. + */ +type TokenItem = { + value: string; + title: string; +}; type BlockVisibilityAttributes = { newspackAccessControlRules: BlockVisibilityRules; newspackAccessControlVisibility: string; From e8dff428e9855afcdb3ef518c8192e4fc26625e9 Mon Sep 17 00:00:00 2001 From: dkoo Date: Thu, 16 Apr 2026 11:42:28 -0600 Subject: [PATCH 34/34] Revert "refactor(content-gate): use TokenItem objects in FormTokenField for ID-based selection" This reverts commit fdb124a581f61b5304a72804be17600c8597bb0b. --- src/content-gate/editor/block-visibility.tsx | 46 +++++--------------- src/content-gate/editor/index.d.ts | 8 ---- 2 files changed, 11 insertions(+), 43 deletions(-) diff --git a/src/content-gate/editor/block-visibility.tsx b/src/content-gate/editor/block-visibility.tsx index 2619980f5e..d23fe1cbf1 100644 --- a/src/content-gate/editor/block-visibility.tsx +++ b/src/content-gate/editor/block-visibility.tsx @@ -119,31 +119,17 @@ const VisibilityControl = ( { * A reader needs to satisfy any one of the selected gates' rules to match. */ const GateControls = ( { gateIds, onChange }: { gateIds: number[]; onChange: ( ids: number[] ) => void } ) => { - // Use token objects so removal is keyed by gate ID, not title string. - // This means two gates with the same title can coexist as tokens without - // one removal accidentally removing both. - const selectedTokens: TokenItem[] = availableGates - .filter( g => gateIds.includes( g.id ) ) - .map( g => ( { value: String( g.id ), title: g.title } ) ); + const selectedLabels = availableGates.filter( g => gateIds.includes( g.id ) ).map( g => g.title ); return ( g.title ) } - onChange={ ( tokens: ( string | TokenItem )[] ) => { - const newIds = tokens.flatMap( t => { - if ( typeof t !== 'string' ) { - // Existing token — value is the stringified gate ID. - const id = parseInt( t.value, 10 ); - return isNaN( id ) ? [] : [ id ]; - } - // New token from suggestions — look up by title. - const gate = availableGates.find( g => g.title === t ); - return gate ? [ gate.id ] : []; - } ); - onChange( newIds ); + onChange={ ( tokens: ( string | { value: string } )[] ) => { + const labels = tokens.map( t => ( typeof t === 'string' ? t : t.value ) ); + onChange( availableGates.filter( g => labels.includes( g.title ) ).map( g => g.id ) ); } } __experimentalExpandOnFocus __next40pxDefaultSize @@ -201,28 +187,18 @@ const AccessRuleValueControl = ( { }, [ slug ] ); // eslint-disable-line react-hooks/exhaustive-deps if ( options.length > 0 ) { + // Map stored IDs to labels for display; silently drop IDs with no matching option. const valueArr = Array.isArray( value ) ? value : []; - // Use token objects so removal is keyed by option value, not label string. - const selectedTokens: TokenItem[] = options - .filter( o => valueArr.some( v => String( v ) === String( o.value ) ) ) - .map( o => ( { value: String( o.value ), title: o.label } ) ); + const selectedLabels = options.filter( o => valueArr.some( v => String( v ) === String( o.value ) ) ).map( o => o.label ); return ( o.label ) } - onChange={ ( tokens: ( string | TokenItem )[] ) => { - const newValues = tokens.flatMap( t => { - if ( typeof t !== 'string' ) { - // Existing token — value is String(option.value). - return [ t.value ]; - } - // New token from suggestions — look up by label. - const opt = options.find( o => o.label === t ); - return opt ? [ String( opt.value ) ] : []; - } ); - onChange( newValues ); + onChange={ ( tokens: ( string | { value: string } )[] ) => { + const labels = tokens.map( t => ( typeof t === 'string' ? t : t.value ) ); + onChange( options.filter( o => labels.includes( o.label ) ).map( o => o.value ) ); } } __experimentalExpandOnFocus __next40pxDefaultSize diff --git a/src/content-gate/editor/index.d.ts b/src/content-gate/editor/index.d.ts index eab6675390..ef01c886f8 100644 --- a/src/content-gate/editor/index.d.ts +++ b/src/content-gate/editor/index.d.ts @@ -45,14 +45,6 @@ type GateOption = { id: number; title: string; }; -/** - * FormTokenField token object — value is used for identity/removal, - * title is displayed in the chip. Matches the WordPress TokenItem shape. - */ -type TokenItem = { - value: string; - title: string; -}; type BlockVisibilityAttributes = { newspackAccessControlRules: BlockVisibilityRules; newspackAccessControlVisibility: string;