diff --git a/assets/js/googlesitekit-admin-post-list-ga4-helpers.test.ts b/assets/js/googlesitekit-admin-post-list-ga4-helpers.test.ts new file mode 100644 index 00000000000..1d55caee013 --- /dev/null +++ b/assets/js/googlesitekit-admin-post-list-ga4-helpers.test.ts @@ -0,0 +1,63 @@ +/** + * Site Kit by Google, Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Internal dependencies + */ +import { + chunkArray, + parseRowsToPathMap, +} from './googlesitekit-admin-post-list-ga4-helpers'; + +describe( 'chunkArray', () => { + it( 'returns empty array for empty input', () => { + expect( chunkArray( [], 3 ) ).toStrictEqual( [] ); + } ); + + it( 'chunks into fixed sizes', () => { + expect( chunkArray( [ 1, 2, 3, 4, 5 ], 2 ) ).toStrictEqual( [ + [ 1, 2 ], + [ 3, 4 ], + [ 5 ], + ] ); + } ); +} ); + +describe( 'parseRowsToPathMap', () => { + it( 'returns empty object when rows missing', () => { + expect( parseRowsToPathMap( {} ) ).toStrictEqual( {} ); + expect( parseRowsToPathMap( { rows: [] } ) ).toStrictEqual( {} ); + } ); + + it( 'maps dimension to first metric value', () => { + const report = { + rows: [ + { + dimensionValues: [ { value: '/foo' } ], + metricValues: [ { value: '10' } ], + }, + { + dimensionValues: [ { value: '/bar?x=1' } ], + metricValues: [ { value: '3' } ], + }, + ], + }; + expect( parseRowsToPathMap( report ) ).toStrictEqual( { + '/foo': '10', + '/bar?x=1': '3', + } ); + } ); +} ); diff --git a/assets/js/googlesitekit-admin-post-list-ga4-helpers.ts b/assets/js/googlesitekit-admin-post-list-ga4-helpers.ts new file mode 100644 index 00000000000..4ef34266b76 --- /dev/null +++ b/assets/js/googlesitekit-admin-post-list-ga4-helpers.ts @@ -0,0 +1,81 @@ +/** + * Site Kit by Google, Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Minimal GA4 report row shape from `get( 'modules', …, 'report', … )`. + * + * @since n.e.x.t + */ +export interface ReportRow { + dimensionValues?: Array< { value?: string } >; + metricValues?: Array< { value?: string } >; +} + +/** + * Report payload accepted by {@link parseRowsToPathMap}. + * + * @since n.e.x.t + */ +export interface ReportPayload { + rows?: ReportRow[]; +} + +/** + * Splits an array into chunks of at most `size` elements. + * + * @since n.e.x.t + * + * @param arr Input array. + * @param size Chunk size (positive integer). + * @return Chunk arrays. + */ +export function chunkArray< T >( arr: T[], size: number ): T[][] { + const out: T[][] = []; + for ( let index = 0; index < arr.length; index += size ) { + out.push( arr.slice( index, index + size ) ); + } + return out; +} + +/** + * Maps GA4 report rows to path → first metric value. + * + * @since n.e.x.t + * + * @param report Report payload from `get()`. + * @return Path keys to metric value strings. + */ +export function parseRowsToPathMap( + report: ReportPayload | null | undefined +): Record< string, string > { + const map: Record< string, string > = {}; + if ( ! report?.rows?.length ) { + return map; + } + for ( const row of report.rows ) { + const dimValue = row.dimensionValues?.[ 0 ]?.value; + const metValue = row.metricValues?.[ 0 ]?.value; + if ( + dimValue !== undefined && + dimValue !== null && + metValue !== undefined && + metValue !== null + ) { + map[ dimValue ] = metValue; + } + } + return map; +} diff --git a/assets/js/googlesitekit-admin-post-list-ga4.ts b/assets/js/googlesitekit-admin-post-list-ga4.ts new file mode 100644 index 00000000000..a297a14f29e --- /dev/null +++ b/assets/js/googlesitekit-admin-post-list-ga4.ts @@ -0,0 +1,161 @@ +/** + * Site Kit by Google, Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * WordPress dependencies + */ +import domReady from '@wordpress/dom-ready'; + +/** + * External dependencies + */ +import Data from 'googlesitekit-data'; +import { get } from 'googlesitekit-api'; + +/** + * Internal dependencies + */ +import { CORE_USER } from '@/js/googlesitekit/datastore/user/constants'; +import { DATE_RANGE_OFFSET } from '@/js/modules/analytics-4/datastore/constants'; +import { numFmt } from '@/js/util/i18n'; +import { + chunkArray, + parseRowsToPathMap, + type ReportPayload, +} from './googlesitekit-admin-post-list-ga4-helpers'; + +const CHUNK_SIZE = 80; + +/** + * Inline config from PHP `Script_Data` (`googlesitekit-admin-post-list-ga4-data` → `_googlesitekitPostListGA4Data`). + * + * @since n.e.x.t + */ +export interface PostListGA4Config { + metric?: string; + dateRangeSlug?: string; + moduleSlug?: string; +} + +declare global { + interface Window { + _googlesitekitPostListGA4Data?: PostListGA4Config; + } +} + +function getConfig(): PostListGA4Config { + if ( typeof window === 'undefined' ) { + return {}; + } + return window._googlesitekitPostListGA4Data ?? {}; +} + +function collectPaths(): string[] { + const spans = document.querySelectorAll( + '.googlesitekit-post-list-ga4-views[data-page-path]' + ); + const paths = new Set< string >(); + spans.forEach( ( element ) => { + const p = element.getAttribute( 'data-page-path' ); + if ( p ) { + paths.add( p ); + } + } ); + return Array.from( paths ); +} + +async function fetchReportForPaths( + paths: string[], + metric: string, + startDate: string, + endDate: string, + moduleSlug: string +): Promise< Record< string, string > > { + const merged: Record< string, string > = {}; + for ( const batch of chunkArray( paths, CHUNK_SIZE ) ) { + if ( ! batch.length ) { + continue; + } + const report = ( await get( 'modules', moduleSlug, 'report', { + dimensions: [ { name: 'pagePathPlusQueryString' } ], + metrics: [ { name: metric } ], + dimensionFilters: { + pagePathPlusQueryString: batch, + }, + startDate, + endDate, + limit: Math.min( batch.length, 1000 ), + } ) ) as ReportPayload; + Object.assign( merged, parseRowsToPathMap( report ) ); + } + return merged; +} + +function applyValuesToDom( pathToValue: Record< string, string > ): void { + document + .querySelectorAll( + '.googlesitekit-post-list-ga4-views[data-page-path]' + ) + .forEach( ( element ) => { + const path = element.getAttribute( 'data-page-path' ); + if ( ! path ) { + return; + } + const raw = pathToValue[ path ]; + if ( raw === undefined ) { + element.textContent = numFmt( 0 ); + return; + } + const num = parseFloat( raw ); + if ( Number.isNaN( num ) ) { + element.textContent = String( raw ); + } else { + element.textContent = numFmt( num ); + } + } ); +} + +domReady( async () => { + const cfg = getConfig(); + const { metric, dateRangeSlug, moduleSlug } = cfg; + if ( ! metric || ! dateRangeSlug || ! moduleSlug ) { + return; + } + + const paths = collectPaths(); + if ( ! paths.length ) { + return; + } + + try { + Data.dispatch( CORE_USER ).setDateRange( dateRangeSlug ); + const { startDate, endDate } = Data.select( + CORE_USER + ).getDateRangeDates( { offsetDays: DATE_RANGE_OFFSET } ); + + const pathToValue = await fetchReportForPaths( + paths, + metric, + startDate, + endDate, + moduleSlug + ); + + applyValuesToDom( pathToValue ); + } catch { + // Keep loading placeholder on failure. + } +} ); diff --git a/assets/webpack/modules.config.js b/assets/webpack/modules.config.js index 69edaeeabe0..62095356441 100644 --- a/assets/webpack/modules.config.js +++ b/assets/webpack/modules.config.js @@ -91,6 +91,8 @@ module.exports = function ( mode, rules ) { './js/googlesitekit-metric-selection.js', 'googlesitekit-key-metrics-setup': './js/googlesitekit-key-metrics-setup.js', + 'googlesitekit-admin-post-list-ga4': + './js/googlesitekit-admin-post-list-ga4.ts', // Old Modules 'googlesitekit-activation': './js/googlesitekit-activation.js', 'googlesitekit-adminbar': './js/googlesitekit-adminbar.js', diff --git a/includes/Core/Assets/Assets.php b/includes/Core/Assets/Assets.php index 8ca267a9d8e..b162f5bc1ae 100644 --- a/includes/Core/Assets/Assets.php +++ b/includes/Core/Assets/Assets.php @@ -102,8 +102,7 @@ function ( $tag, $handle ) { 'admin_print_scripts-edit.php', function () { global $post_type; - if ( 'post' !== $post_type ) { - // For CONTEXT_ADMIN_POSTS we only load scripts for the 'post' post type. + if ( empty( $post_type ) || ! is_string( $post_type ) || ! is_post_type_viewable( $post_type ) ) { return; } $assets = $this->get_assets(); diff --git a/includes/Modules/Analytics_4.php b/includes/Modules/Analytics_4.php index ef2df12c007..eec3e5dea6a 100644 --- a/includes/Modules/Analytics_4.php +++ b/includes/Modules/Analytics_4.php @@ -17,6 +17,7 @@ use Google\Site_Kit\Core\Assets\Asset; use Google\Site_Kit\Core\Assets\Assets; use Google\Site_Kit\Core\Assets\Script; +use Google\Site_Kit\Core\Assets\Script_Data; use Google\Site_Kit\Core\Authentication\Authentication; use Google\Site_Kit\Core\Authentication\Clients\Google_Site_Kit_Client; use Google\Site_Kit\Core\Dismissals\Dismissed_Items; @@ -71,6 +72,8 @@ use Google\Site_Kit\Modules\Analytics_4\Synchronize_Property; use Google\Site_Kit\Modules\Analytics_4\Synchronize_AdSenseLinked; use Google\Site_Kit\Modules\Analytics_4\GoogleAnalyticsAdmin\AccountProvisioningService; +use Google\Site_Kit\Modules\Analytics_4\Post_List_Column_Preferences; +use Google\Site_Kit\Modules\Analytics_4\Post_List_View_Analytics_Column; use Google\Site_Kit\Modules\Analytics_4\Report\Request as Analytics_4_Report_Request; use Google\Site_Kit\Modules\Analytics_4\Report\Response as Analytics_4_Report_Response; use Google\Site_Kit\Modules\Analytics_4\Resource_Data_Availability_Date; @@ -192,6 +195,20 @@ final class Analytics_4 extends Module implements Module_With_Inline_Data, Modul */ protected $audience_utilities; + /** + * GA4 post list column preferences. + * + * @var Post_List_Column_Preferences + */ + protected $post_list_column_preferences; + + /** + * GA4 post list column. + * + * @var Post_List_View_Analytics_Column + */ + protected $post_list_view_analytics_column; + /** * Constructor. * @@ -216,6 +233,12 @@ public function __construct( $this->audience_settings = new Audience_Settings( $this->options ); $this->audience_utilities = new Audience_Utilities( $this->audience_settings ); $this->resource_data_availability_date = new Resource_Data_Availability_Date( $this->transients, $this->get_settings(), $this->audience_settings ); + + $this->post_list_column_preferences = new Post_List_Column_Preferences( $this->user_options ); + $this->post_list_view_analytics_column = new Post_List_View_Analytics_Column( + $this->context, + $this->post_list_column_preferences + ); } /** @@ -229,6 +252,10 @@ public function register() { $this->register_inline_data(); + if ( $this->is_connected() ) { + $this->post_list_view_analytics_column->register(); + } + $this->register_feature_metrics(); $synchronize_property = new Synchronize_Property( @@ -1861,7 +1888,8 @@ protected function setup_settings() { * @return Asset[] List of Asset objects. */ protected function setup_assets() { - $base_url = $this->context->url( 'dist/assets/' ); + $base_url = $this->context->url( 'dist/assets/' ); + $post_list_ga4_col = $this->post_list_view_analytics_column; return array( new Script( @@ -1882,6 +1910,30 @@ protected function setup_assets() { ), ) ), + new Script_Data( + 'googlesitekit-admin-post-list-ga4-data', + array( + 'global' => '_googlesitekitPostListGA4Data', + 'data_callback' => function () use ( $post_list_ga4_col ) { + if ( ! current_user_can( Permissions::VIEW_POSTS_INSIGHTS ) ) { + return array(); + } + + return $post_list_ga4_col->get_inline_script_data(); + }, + ) + ), + new Script( + 'googlesitekit-admin-post-list-ga4', + array( + 'src' => $base_url . 'js/googlesitekit-admin-post-list-ga4.js', + 'dependencies' => array( + 'googlesitekit-admin-post-list-ga4-data', + 'googlesitekit-datastore-user', + ), + 'load_contexts' => array( Asset::CONTEXT_ADMIN_POSTS ), + ) + ), ); } diff --git a/includes/Modules/Analytics_4/Post_List_Column_Date_Range_User_Setting.php b/includes/Modules/Analytics_4/Post_List_Column_Date_Range_User_Setting.php new file mode 100644 index 00000000000..3f3c6ba71a0 --- /dev/null +++ b/includes/Modules/Analytics_4/Post_List_Column_Date_Range_User_Setting.php @@ -0,0 +1,54 @@ +metric_setting = new Post_List_Column_Metric_User_Setting( $user_options ); + $this->date_range_setting = new Post_List_Column_Date_Range_User_Setting( $user_options ); + } + + /** + * Registers user meta for both settings. + * + * @since n.e.x.t + */ + public function register() { + $this->metric_setting->register(); + $this->date_range_setting->register(); + } + + /** + * Gets the selected metric API name. + * + * @since n.e.x.t + * + * @return string + */ + public function get_metric() { + $value = $this->metric_setting->get(); + return in_array( $value, self::ALLOWED_METRICS, true ) ? $value : self::DEFAULT_METRIC; + } + + /** + * Gets the selected date range slug. + * + * @since n.e.x.t + * + * @return string + */ + public function get_date_range_slug() { + $value = $this->date_range_setting->get(); + return in_array( $value, self::ALLOWED_DATE_RANGES, true ) ? $value : self::DEFAULT_DATE_RANGE; + } + + /** + * Sets metric from raw input (sanitized). + * + * @since n.e.x.t + * + * @param string $metric Metric name. + * @return bool + */ + public function set_metric( $metric ) { + if ( ! in_array( $metric, self::ALLOWED_METRICS, true ) ) { + $metric = self::DEFAULT_METRIC; + } + return $this->metric_setting->set( $metric ); + } + + /** + * Sets date range from raw input (sanitized). + * + * @since n.e.x.t + * + * @param string $slug Date range slug. + * @return bool + */ + public function set_date_range_slug( $slug ) { + if ( ! in_array( $slug, self::ALLOWED_DATE_RANGES, true ) ) { + $slug = self::DEFAULT_DATE_RANGE; + } + return $this->date_range_setting->set( $slug ); + } + + /** + * Human-readable labels for metrics (column header). + * + * @since n.e.x.t + * + * @return array + */ + public static function get_metric_labels() { + return array( + 'screenPageViews' => __( 'Page views', 'google-site-kit' ), + 'sessions' => __( 'Sessions', 'google-site-kit' ), + 'engagedSessions' => __( 'Engaged sessions', 'google-site-kit' ), + 'activeUsers' => __( 'Active users', 'google-site-kit' ), + ); + } + + /** + * Human-readable labels for date ranges (tooltip). + * + * @since n.e.x.t + * + * @return array + */ + public static function get_date_range_labels() { + return array( + 'last-7-days' => __( 'Last 7 days', 'google-site-kit' ), + 'last-14-days' => __( 'Last 14 days', 'google-site-kit' ), + 'last-28-days' => __( 'Last 28 days', 'google-site-kit' ), + 'last-90-days' => __( 'Last 90 days', 'google-site-kit' ), + ); + } +} diff --git a/includes/Modules/Analytics_4/Post_List_View_Analytics_Column.php b/includes/Modules/Analytics_4/Post_List_View_Analytics_Column.php new file mode 100644 index 00000000000..54b788f44ac --- /dev/null +++ b/includes/Modules/Analytics_4/Post_List_View_Analytics_Column.php @@ -0,0 +1,379 @@ +context = $context; + $this->preferences = $preferences; + } + + /** + * Registers WordPress hooks. + * + * List-table hooks use `admin_init` so `current_user_can( VIEW_POSTS_INSIGHTS )` runs after + * {@see \Google\Site_Kit\Core\Permissions\Permissions::register()} (registered later in the same + * core `init` pass as {@see \Google\Site_Kit\Core\Modules\Modules::register()}). + * + * Screen Options “Apply” is processed by core’s {@see set_screen_options()} before `admin_init` + * (then redirect + exit), so preference persistence must use `init` — not `load-edit.php`. + * + * Call only when Analytics is connected ({@see \Google\Site_Kit\Modules\Analytics_4::register()}). + * + * @since n.e.x.t + */ + public function register() { + add_action( 'init', array( $this, 'maybe_save_post_list_screen_options' ), 20 ); + add_action( 'admin_init', array( $this, 'register_list_table_features' ) ); + } + + /** + * Registers column and screen options when the user may view posts insights. + * + * @since n.e.x.t + */ + public function register_list_table_features() { + if ( ! current_user_can( Permissions::VIEW_POSTS_INSIGHTS ) ) { + return; + } + + $this->preferences->register(); + + foreach ( $this->get_viewable_post_types() as $post_type ) { + add_filter( + "manage_{$post_type}_posts_columns", + array( $this, 'add_column' ) + ); + add_action( + "manage_{$post_type}_posts_custom_column", + array( $this, 'render_column' ), + 10, + 2 + ); + } + + add_filter( 'screen_settings', array( $this, 'filter_screen_settings' ), 10, 2 ); + } + + /** + * Gets post types that should show the column. + * + * @since n.e.x.t + * + * @return string[] + */ + private function get_viewable_post_types() { + return array_values( + array_filter( + get_post_types( array(), 'names' ), + 'is_post_type_viewable' + ) + ); + } + + /** + * Adds the GA4 column to the list table (appended after existing columns). + * + * @since n.e.x.t + * + * @param array $columns Existing columns. + * @return array + */ + public function add_column( $columns ) { + if ( ! is_array( $columns ) ) { + return $columns; + } + + $columns[ self::COLUMN_ID ] = $this->get_column_header_html(); + + return $columns; + } + + /** + * Renders column cell content. + * + * @since n.e.x.t + * + * @param string $column Column key. + * @param int $post_id Post ID. + */ + public function render_column( $column, $post_id ) { + if ( self::COLUMN_ID !== $column ) { + return; + } + + $permalink = get_permalink( $post_id ); + if ( empty( $permalink ) || ! is_string( $permalink ) ) { + echo esc_html( _x( '—', 'No public URL for post', 'google-site-kit' ) ); + return; + } + + $path = $this->get_page_path_for_ga( $permalink ); + if ( '' === $path ) { + echo esc_html( _x( '—', 'Could not resolve path for analytics', 'google-site-kit' ) ); + return; + } + + printf( + '%s', + esc_attr( $path ), + esc_html( _x( '—', 'Placeholder before analytics load', 'google-site-kit' ) ) + ); + } + + /** + * Builds page path string for GA4 pagePathPlusQueryString matching. + * + * @since n.e.x.t + * + * @param string $permalink Full permalink. + * @return string Path plus optional query (leading slash). + */ + private function get_page_path_for_ga( $permalink ) { + $parts = URL::parse( $permalink ); + if ( ! is_array( $parts ) ) { + return ''; + } + if ( empty( $parts['path'] ) && empty( $parts['query'] ) ) { + return ''; + } + $path = isset( $parts['path'] ) ? $parts['path'] : '/'; + if ( ! empty( $parts['query'] ) ) { + $path .= '?' . $parts['query']; + } + return $path; + } + + /** + * Column header markup: icon + metric label, with tooltip. + * + * @since n.e.x.t + * + * @return string + */ + private function get_column_header_html() { + $metric = $this->preferences->get_metric(); + $date_slug = $this->preferences->get_date_range_slug(); + $labels = Post_List_Column_Preferences::get_metric_labels(); + $date_labels = Post_List_Column_Preferences::get_date_range_labels(); + + $metric_label = isset( $labels[ $metric ] ) ? $labels[ $metric ] : $metric; + $date_label = isset( $date_labels[ $date_slug ] ) ? $date_labels[ $date_slug ] : $date_slug; + + $tooltip = sprintf( + /* translators: 1: Metric label. 2: Date range label. */ + __( '%1$s over %2$s via Site Kit by Google', 'google-site-kit' ), + $metric_label, + $date_label + ); + + $icon_url = $this->context->url( 'assets/svg/graphics/analytics.svg' ); + + return sprintf( + ' %3$s', + esc_attr( $tooltip ), + esc_url( $icon_url ), + esc_html( $metric_label ) + ); + } + + /** + * Appends Screen Options fields for metric and date range. + * + * @since n.e.x.t + * + * @param string $settings Screen settings HTML. + * @param WP_Screen $screen Current screen. + * @return string + */ + public function filter_screen_settings( $settings, $screen ) { + if ( ! $screen instanceof WP_Screen || 'edit' !== $screen->base ) { + return $settings; + } + + if ( empty( $screen->post_type ) || ! is_post_type_viewable( $screen->post_type ) ) { + return $settings; + } + + if ( ! current_user_can( Permissions::VIEW_POSTS_INSIGHTS ) ) { + return $settings; + } + + $settings .= $this->get_screen_options_fieldset_markup( + $this->preferences->get_metric(), + $this->preferences->get_date_range_slug() + ); + + return $settings; + } + + /** + * Persists Screen Options for metric and date range (see {@see self::register()}). + * + * @since n.e.x.t + */ + public function maybe_save_post_list_screen_options() { + if ( ! is_admin() || wp_doing_ajax() || ! $this->is_post_list_edit_request() ) { + return; + } + + if ( ! isset( $_POST['screen-options-apply'] ) ) { + return; + } + + $metric_key = Post_List_Column_Preferences::OPTION_METRIC; + $date_key = Post_List_Column_Preferences::OPTION_DATE_RANGE; + if ( ! isset( $_POST[ $metric_key ], $_POST[ $date_key ] ) ) { + return; + } + + if ( ! current_user_can( Permissions::VIEW_POSTS_INSIGHTS ) ) { + return; + } + + check_admin_referer( 'screen-options-nonce', 'screenoptionnonce' ); + + $post_type = isset( $_REQUEST['post_type'] ) + ? sanitize_key( wp_unslash( $_REQUEST['post_type'] ) ) + : 'post'; + if ( ! post_type_exists( $post_type ) || ! is_post_type_viewable( $post_type ) ) { + return; + } + + $metric = sanitize_text_field( wp_unslash( $_POST[ $metric_key ] ) ); + $date = sanitize_text_field( wp_unslash( $_POST[ $date_key ] ) ); + + $this->preferences->set_metric( $metric ); + $this->preferences->set_date_range_slug( $date ); + } + + /** + * Whether the current request is a direct load/post of wp-admin/edit.php. + * + * @since n.e.x.t + * + * @return bool + */ + private function is_post_list_edit_request() { + if ( empty( $_SERVER['PHP_SELF'] ) || ! is_string( $_SERVER['PHP_SELF'] ) ) { + return false; + } + + $php_self = sanitize_text_field( wp_unslash( $_SERVER['PHP_SELF'] ) ); + + return 'edit.php' === wp_basename( $php_self ); + } + + /** + * Markup for Screen Options fieldset (shared option names with {@see Post_List_Column_Preferences}). + * + * @since n.e.x.t + * + * @param string $current_metric Saved metric key. + * @param string $current_date Saved date range slug. + * @return string + */ + private function get_screen_options_fieldset_markup( $current_metric, $current_date ) { + $metric_key = Post_List_Column_Preferences::OPTION_METRIC; + $date_key = Post_List_Column_Preferences::OPTION_DATE_RANGE; + + ob_start(); + ?> +
+ + + + + +
+ + */ + public function get_inline_script_data() { + return array( + 'metric' => $this->preferences->get_metric(), + 'dateRangeSlug' => $this->preferences->get_date_range_slug(), + 'moduleSlug' => Analytics_4_Module::MODULE_SLUG, + ); + } +} diff --git a/tests/phpunit/integration/Modules/Analytics_4/Post_List_Column_PreferencesTest.php b/tests/phpunit/integration/Modules/Analytics_4/Post_List_Column_PreferencesTest.php new file mode 100644 index 00000000000..b023080a0d9 --- /dev/null +++ b/tests/phpunit/integration/Modules/Analytics_4/Post_List_Column_PreferencesTest.php @@ -0,0 +1,81 @@ +factory()->user->create( array( 'role' => 'administrator' ) ); + wp_set_current_user( $user_id ); + + $context = new Context( GOOGLESITEKIT_PLUGIN_MAIN_FILE ); + $this->user_options = new User_Options( $context, $user_id ); + $this->preferences = new Post_List_Column_Preferences( $this->user_options ); + $this->preferences->register(); + } + + public function test_get_metric_defaults_when_unset() { + $this->assertSame( + Post_List_Column_Preferences::DEFAULT_METRIC, + $this->preferences->get_metric(), + 'Unset metric should fall back to default.' + ); + } + + public function test_set_metric_rejects_invalid_value() { + $this->preferences->set_metric( 'invalidMetric' ); + $this->assertSame( + Post_List_Column_Preferences::DEFAULT_METRIC, + $this->preferences->get_metric(), + 'Invalid metric should normalize to default.' + ); + } + + public function test_set_metric_accepts_allowed_value() { + $this->preferences->set_metric( 'sessions' ); + $this->assertSame( 'sessions', $this->preferences->get_metric(), 'Allowed metric should persist.' ); + } + + public function test_set_date_range_slug_rejects_invalid_value() { + $this->preferences->set_date_range_slug( 'not-a-range' ); + $this->assertSame( + Post_List_Column_Preferences::DEFAULT_DATE_RANGE, + $this->preferences->get_date_range_slug(), + 'Invalid date range should normalize to default.' + ); + } + + public function test_set_date_range_slug_accepts_allowed_value() { + $this->preferences->set_date_range_slug( 'last-7-days' ); + $this->assertSame( 'last-7-days', $this->preferences->get_date_range_slug(), 'Allowed slug should persist.' ); + } +} diff --git a/tests/phpunit/integration/Modules/Analytics_4/Post_List_View_Analytics_ColumnTest.php b/tests/phpunit/integration/Modules/Analytics_4/Post_List_View_Analytics_ColumnTest.php new file mode 100644 index 00000000000..e5382f3de8b --- /dev/null +++ b/tests/phpunit/integration/Modules/Analytics_4/Post_List_View_Analytics_ColumnTest.php @@ -0,0 +1,267 @@ +factory()->user->create( array( 'role' => 'administrator' ) ); + wp_set_current_user( $user_id ); + + $this->map_meta_cap_view_posts_insights = static function ( $caps, $cap ) { + if ( Permissions::VIEW_POSTS_INSIGHTS === $cap ) { + return array( 'read' ); + } + return $caps; + }; + add_filter( 'map_meta_cap', $this->map_meta_cap_view_posts_insights, 10, 4 ); + + $this->context = new Context( GOOGLESITEKIT_PLUGIN_MAIN_FILE ); + $user_opts = new User_Options( $this->context, $user_id ); + $this->preferences = new Post_List_Column_Preferences( $user_opts ); + + $this->create_column(); + } + + public function tear_down(): void { + remove_filter( 'map_meta_cap', $this->map_meta_cap_view_posts_insights, 10 ); + parent::tear_down(); + } + + private function create_column() { + $this->column = new Post_List_View_Analytics_Column( + $this->context, + $this->preferences + ); + } + + public function test_register_schedules_init_and_admin_init() { + $this->column->register(); + $this->assertNotFalse( + has_action( 'init', array( $this->column, 'maybe_save_post_list_screen_options' ) ), + 'register() should persist Screen Options on init (before core set_screen_options redirect).' + ); + $this->assertNotFalse( + has_action( 'admin_init', array( $this->column, 'register_list_table_features' ) ), + 'register() should defer list-table hooks to admin_init (after Permissions::register).' + ); + } + + public function test_register_list_table_features_adds_column_hooks() { + $this->column->register_list_table_features(); + $this->assertNotFalse( + has_filter( 'manage_post_posts_columns', array( $this->column, 'add_column' ) ), + 'List-table registration should add manage_*_posts_columns when the user may view insights.' + ); + } + + public function test_add_column_appends_ga4_column() { + $columns = array( + 'cb' => '', + 'title' => 'Title', + 'author' => 'Author', + 'date' => 'Date', + ); + $out = $this->column->add_column( $columns ); + $keys = array_keys( $out ); + + $this->assertArrayHasKey( Post_List_View_Analytics_Column::COLUMN_ID, $out, 'GA4 column should be registered.' ); + $this->assertStringContainsString( + 'googlesitekit-post-list-ga4-header', + $out[ Post_List_View_Analytics_Column::COLUMN_ID ], + 'GA4 column header markup should be present.' + ); + $this->assertSame( + Post_List_View_Analytics_Column::COLUMN_ID, + end( $keys ), + 'GA4 column should be appended after existing columns.' + ); + } + + public function test_render_column_outputs_placeholder_with_page_path() { + $post_id = $this->factory()->post->create( + array( + 'post_title' => 'Hello', + 'post_status' => 'publish', + ) + ); + + ob_start(); + $this->column->render_column( Post_List_View_Analytics_Column::COLUMN_ID, $post_id ); + $html = ob_get_clean(); + + $this->assertStringContainsString( + 'googlesitekit-post-list-ga4-views', + $html, + 'Cell should use the GA4 views wrapper class.' + ); + $this->assertStringContainsString( + 'data-page-path=', + $html, + 'Cell should expose data-page-path for the admin script.' + ); + $permalink = get_permalink( $post_id ); + $expected_path = wp_parse_url( $permalink, PHP_URL_PATH ); + $this->assertNotEmpty( $expected_path, 'Published post should have a URL path.' ); + $this->assertStringContainsString( + esc_attr( $expected_path ), + $html, + 'data-page-path should match the post permalink path.' + ); + } + + public function test_get_column_header_has_icon_and_title_tooltip() { + $method = new ReflectionMethod( Post_List_View_Analytics_Column::class, 'get_column_header_html' ); + $method->setAccessible( true ); + $html = $method->invoke( $this->column ); + + $this->assertStringContainsString( + 'googlesitekit-post-list-ga4-header', + $html, + 'Header should use the expected wrapper class.' + ); + $this->assertStringContainsString( + 'googlesitekit-post-list-ga4-icon', + $html, + 'Header should include the icon element.' + ); + $this->assertStringContainsString( + 'analytics.svg', + $html, + 'Header should reference the analytics icon asset.' + ); + $this->assertMatchesRegularExpression( + '/title="[^"]*via Site Kit by Google"/', + $html, + 'Header title tooltip should mention Site Kit.' + ); + } + + public function test_filter_screen_settings_appends_fieldset_on_edit_screen() { + require_once ABSPATH . 'wp-admin/includes/class-wp-screen.php'; + $screen = WP_Screen::get( 'edit-post' ); + $this->assertInstanceOf( + WP_Screen::class, + $screen, + 'edit-post screen should resolve to WP_Screen.' + ); + $this->assertSame( 'post', $screen->post_type, 'Posts list screen should have post_type post.' ); + + $out = $this->column->filter_screen_settings( '

Core

', $screen ); + $this->assertStringContainsString( + 'googlesitekit-post-list-ga4-screen-options', + $out, + 'Screen options should include the Site Kit fieldset.' + ); + $this->assertStringContainsString( + 'name="googlesitekit_ga4_post_list_metric"', + $out, + 'Screen options should include metric select name.' + ); + $this->assertStringContainsString( + 'name="googlesitekit_ga4_post_list_date_range"', + $out, + 'Screen options should include date range select name.' + ); + } + + public function test_maybe_save_post_list_screen_options_persists_preferences() { + $preferences = $this->get_column_preferences_from_column(); + $this->assertInstanceOf( + Post_List_Column_Preferences::class, + $preferences, + 'Column should hold Post_List_Column_Preferences.' + ); + + $prev_php_self = isset( $_SERVER['PHP_SELF'] ) ? $_SERVER['PHP_SELF'] : null; + $had_wp_admin = defined( 'WP_ADMIN' ); + if ( ! $had_wp_admin ) { + define( 'WP_ADMIN', true ); + } + $_SERVER['PHP_SELF'] = '/wp-admin/edit.php'; + $_POST[ Post_List_Column_Preferences::OPTION_METRIC ] = 'sessions'; + $_POST[ Post_List_Column_Preferences::OPTION_DATE_RANGE ] = 'last-7-days'; + $_POST['screenoptionnonce'] = wp_create_nonce( 'screen-options-nonce' ); + $_POST['screen-options-apply'] = '1'; + $_REQUEST['screenoptionnonce'] = $_POST['screenoptionnonce']; + + $this->column->maybe_save_post_list_screen_options(); + + $this->assertSame( + 'sessions', + $preferences->get_metric(), + 'Screen options save should persist metric preference.' + ); + $this->assertSame( + 'last-7-days', + $preferences->get_date_range_slug(), + 'Screen options save should persist date range preference.' + ); + + unset( + $_POST[ Post_List_Column_Preferences::OPTION_METRIC ], + $_POST[ Post_List_Column_Preferences::OPTION_DATE_RANGE ], + $_POST['screenoptionnonce'], + $_POST['screen-options-apply'], + $_REQUEST['screenoptionnonce'] + ); + if ( null === $prev_php_self ) { + unset( $_SERVER['PHP_SELF'] ); + } else { + $_SERVER['PHP_SELF'] = $prev_php_self; + } + // Cannot undefine WP_ADMIN; leave defined if we set it (safe for admin integration tests). + } + + /** + * @return Post_List_Column_Preferences + */ + private function get_column_preferences_from_column() { + $prop = ( new \ReflectionClass( Post_List_View_Analytics_Column::class ) )->getProperty( 'preferences' ); + $prop->setAccessible( true ); + return $prop->getValue( $this->column ); + } +} diff --git a/tests/phpunit/integration/Modules/Analytics_4Test.php b/tests/phpunit/integration/Modules/Analytics_4Test.php index 18150aedf65..5785e08ff1f 100644 --- a/tests/phpunit/integration/Modules/Analytics_4Test.php +++ b/tests/phpunit/integration/Modules/Analytics_4Test.php @@ -33,6 +33,7 @@ use Google\Site_Kit\Modules\Analytics_4\Conversion_Reporting\Conversion_Reporting_Events_Sync; use Google\Site_Kit\Modules\Analytics_4\Conversion_Reporting\Conversion_Reporting_New_Badge_Events_Sync; use Google\Site_Kit\Modules\Analytics_4\Custom_Dimensions_Data_Available; +use Google\Site_Kit\Modules\Analytics_4\Post_List_View_Analytics_Column; use Google\Site_Kit\Modules\Analytics_4\Resource_Data_Availability_Date; use Google\Site_Kit\Modules\Analytics_4\Settings; use Google\Site_Kit\Modules\Analytics_4\Synchronize_AdSenseLinked; @@ -68,6 +69,7 @@ use WP_Query; use WP_User; use ReflectionMethod; +use ReflectionProperty; /** * @group Modules @@ -181,6 +183,73 @@ public function test_register() { $this->assertTrue( has_filter( 'googlesitekit_feature_metrics' ), 'The filter for features metrics should be registered.' ); } + public function test_register_does_not_register_post_list_column_when_not_connected() { + $analytics = $this->new_analytics_with_fresh_options(); + $analytics->get_settings()->merge( + array( + 'accountID' => '', + 'propertyID' => '', + 'webDataStreamID' => '', + 'measurementID' => '', + ) + ); + + $this->assertFalse( $analytics->is_connected(), 'Expected Analytics to be disconnected with default settings.' ); + + $column = $this->get_post_list_view_analytics_column( $analytics ); + + $analytics->register(); + + $this->assertFalse( + has_action( 'init', array( $column, 'maybe_save_post_list_screen_options' ) ), + 'Post list column should not register when Analytics is not connected.' + ); + } + + public function test_register_registers_post_list_column_when_connected() { + $analytics = $this->new_analytics_with_fresh_options(); + + $analytics->get_settings()->merge( + array( + 'accountID' => '12345678', + 'propertyID' => '87654321', + 'webDataStreamID' => '1234567890', + 'measurementID' => 'G-XXXXXXXXXX', + ) + ); + $this->assertTrue( $analytics->is_connected(), 'Expected Analytics to be connected after merging required settings.' ); + + $column = $this->get_post_list_view_analytics_column( $analytics ); + + $analytics->register(); + + $this->assertNotFalse( + has_action( 'init', array( $column, 'maybe_save_post_list_screen_options' ) ), + 'Post list column should register when Analytics is connected.' + ); + } + + /** + * @return Analytics_4 + */ + private function new_analytics_with_fresh_options() { + $context = new Context( GOOGLESITEKIT_PLUGIN_MAIN_FILE ); + $options = new Options( $context ); + $user_options = new User_Options( $context, $this->user->ID ); + $authentication = new Authentication( $context, $options, $user_options ); + + return new Analytics_4( $context, $options, $user_options, $authentication ); + } + + /** + * @param Analytics_4 $analytics Analytics module instance. + */ + private function get_post_list_view_analytics_column( Analytics_4 $analytics ): Post_List_View_Analytics_Column { + $property = new ReflectionProperty( Analytics_4::class, 'post_list_view_analytics_column' ); + $property->setAccessible( true ); + return $property->getValue( $analytics ); + } + public function test_register__reset_adsense_link_settings() { $this->analytics->get_settings()->merge( array(