From d6727ea055bbea184f8d31a01eba2023b9a04a2a Mon Sep 17 00:00:00 2001 From: Thijs van der heijden Date: Thu, 5 Sep 2024 16:03:07 +0200 Subject: [PATCH] Initial new dashboard page --- admin/class-admin-asset-manager.php | 5 + config/webpack/paths.js | 1 + packages/js/src/dashboard/app.js | 31 ++ packages/js/src/dashboard/constants/index.js | 5 + packages/js/src/dashboard/initialize.js | 32 ++ packages/js/src/dashboard/store/index.js | 49 +++ .../js/src/dashboard/store/preferences.js | 34 ++ .../general-page-integration.php | 169 ++++++++++ .../General_Page_Integration_Test.php | 296 ++++++++++++++++++ 9 files changed, 622 insertions(+) create mode 100644 packages/js/src/dashboard/app.js create mode 100644 packages/js/src/dashboard/constants/index.js create mode 100644 packages/js/src/dashboard/initialize.js create mode 100644 packages/js/src/dashboard/store/index.js create mode 100644 packages/js/src/dashboard/store/preferences.js create mode 100644 src/dashboard/user-interface/general-page-integration.php create mode 100644 tests/Unit/Dashboard/User_Interface/General_Page_Integration_Test.php diff --git a/admin/class-admin-asset-manager.php b/admin/class-admin-asset-manager.php index ec0df47a1a3..09d269b7737 100644 --- a/admin/class-admin-asset-manager.php +++ b/admin/class-admin-asset-manager.php @@ -660,6 +660,11 @@ protected function styles_to_be_registered() { 'src' => 'academy-' . $flat_version, 'deps' => [ self::PREFIX . 'tailwind' ], ], + [ + 'name' => 'new-dashboard', + 'src' => 'new-dashboard-' . $flat_version, + 'deps' => [ self::PREFIX . 'tailwind' ], + ], [ 'name' => 'support', 'src' => 'support-' . $flat_version, diff --git a/config/webpack/paths.js b/config/webpack/paths.js index 28132d9f0bd..8633e3f9496 100644 --- a/config/webpack/paths.js +++ b/config/webpack/paths.js @@ -44,6 +44,7 @@ const getEntries = ( sourceDirectory = "./packages/js/src" ) => ( { settings: `${ sourceDirectory }/settings.js`, "new-settings": `${ sourceDirectory }/settings/initialize.js`, academy: `${ sourceDirectory }/academy/initialize.js`, + "new-dashboard": `${ sourceDirectory }/dashboard/initialize.js`, support: `${ sourceDirectory }/support/initialize.js`, "how-to-block": `${ sourceDirectory }/structured-data-blocks/how-to/block.js`, "faq-block": `${ sourceDirectory }/structured-data-blocks/faq/block.js`, diff --git a/packages/js/src/dashboard/app.js b/packages/js/src/dashboard/app.js new file mode 100644 index 00000000000..1544f134d07 --- /dev/null +++ b/packages/js/src/dashboard/app.js @@ -0,0 +1,31 @@ +/* eslint-disable complexity */ + +import { __ } from "@wordpress/i18n"; +import { Paper, Title } from "@yoast/ui-library"; + +/** + * @returns {JSX.Element} The app component. + */ +const App = () => { + return ( +
+ +
+
+ { __( "Alert center", "wordpress-seo" ) } +

+ { __( "Monitor and manage potential SEO problems affecting your site and stay informed with important notifications and updates.", "wordpress-seo" ) } +

+
+
+
+
+ Content +
+
+
+
+ ); +}; + +export default App; diff --git a/packages/js/src/dashboard/constants/index.js b/packages/js/src/dashboard/constants/index.js new file mode 100644 index 00000000000..fa08f25daa4 --- /dev/null +++ b/packages/js/src/dashboard/constants/index.js @@ -0,0 +1,5 @@ +/** + * Keep constants centralized to avoid circular dependency problems. + */ +export const STORE_NAME = "@yoast/dashboard"; + diff --git a/packages/js/src/dashboard/initialize.js b/packages/js/src/dashboard/initialize.js new file mode 100644 index 00000000000..7045b79d2bb --- /dev/null +++ b/packages/js/src/dashboard/initialize.js @@ -0,0 +1,32 @@ +import { SlotFillProvider } from "@wordpress/components"; +import { select } from "@wordpress/data"; +import domReady from "@wordpress/dom-ready"; +import { render } from "@wordpress/element"; +import { Root } from "@yoast/ui-library"; +import { get } from "lodash"; +import { LINK_PARAMS_NAME } from "../shared-admin/store"; +import App from "./app"; +import { STORE_NAME } from "./constants"; +import registerStore from "./store"; + +domReady( () => { + const root = document.getElementById( "yoast-seo-dashboard" ); + if ( ! root ) { + return; + } + registerStore( { + initialState: { + [ LINK_PARAMS_NAME ]: get( window, "wpseoScriptData.linkParams", {} ), + }, + } ); + const isRtl = select( STORE_NAME ).selectPreference( "isRtl", false ); + + render( + + + + + , + root + ); +} ); diff --git a/packages/js/src/dashboard/store/index.js b/packages/js/src/dashboard/store/index.js new file mode 100644 index 00000000000..f16cf812f3e --- /dev/null +++ b/packages/js/src/dashboard/store/index.js @@ -0,0 +1,49 @@ +// eslint-disable-next-line import/named +import { combineReducers, createReduxStore, register } from "@wordpress/data"; +import { merge } from "lodash"; +import { getInitialLinkParamsState, LINK_PARAMS_NAME, linkParamsActions, linkParamsReducer, linkParamsSelectors } from "../../shared-admin/store"; +import { STORE_NAME } from "../constants"; +import preferences, { createInitialPreferencesState, preferencesActions, preferencesSelectors } from "./preferences"; + +/** @typedef {import("@wordpress/data/src/types").WPDataStore} WPDataStore */ + +/** + * @param {Object} initialState Initial state. + * @returns {WPDataStore} The WP data store. + */ +const createStore = ( { initialState } ) => { + return createReduxStore( STORE_NAME, { + actions: { + ...linkParamsActions, + ...preferencesActions, + }, + selectors: { + ...linkParamsSelectors, + ...preferencesSelectors, + }, + initialState: merge( + {}, + { + [ LINK_PARAMS_NAME ]: getInitialLinkParamsState(), + preferences: createInitialPreferencesState(), + }, + initialState + ), + reducer: combineReducers( { + [ LINK_PARAMS_NAME ]: linkParamsReducer, + preferences, + } ), + + } ); +}; + +/** + * Registers the store to WP data's default registry. + * @param {Object} [initialState] Initial state. + * @returns {void} + */ +const registerStore = ( { initialState = {} } = {} ) => { + register( createStore( { initialState } ) ); +}; + +export default registerStore; diff --git a/packages/js/src/dashboard/store/preferences.js b/packages/js/src/dashboard/store/preferences.js new file mode 100644 index 00000000000..08dec22f809 --- /dev/null +++ b/packages/js/src/dashboard/store/preferences.js @@ -0,0 +1,34 @@ +import { createSelector, createSlice } from "@reduxjs/toolkit"; +import { get } from "lodash"; + +/** + * @returns {Object} The initial state. + */ +export const createInitialPreferencesState = () => ( { + ...get( window, "wpseoScriptData.preferences", {} ), +} ); + +const slice = createSlice( { + name: "preferences", + initialState: createInitialPreferencesState(), + reducers: {}, +} ); + +export const preferencesSelectors = { + selectPreference: ( state, preference, defaultValue = {} ) => get( state, `preferences.${ preference }`, defaultValue ), + selectPreferences: state => get( state, "preferences", {} ), +}; +preferencesSelectors.selectUpsellSettingsAsProps = createSelector( + [ + state => preferencesSelectors.selectPreference( state, "upsellSettings", {} ), + ( state, ctbName = "premiumCtbId" ) => ctbName, + ], + ( upsellSettings, ctbName ) => ( { + "data-action": upsellSettings?.actionId, + "data-ctb-id": upsellSettings?.[ ctbName ], + } ) +); + +export const preferencesActions = slice.actions; + +export default slice.reducer; diff --git a/src/dashboard/user-interface/general-page-integration.php b/src/dashboard/user-interface/general-page-integration.php new file mode 100644 index 00000000000..4e20ed59055 --- /dev/null +++ b/src/dashboard/user-interface/general-page-integration.php @@ -0,0 +1,169 @@ +asset_manager = $asset_manager; + $this->current_page_helper = $current_page_helper; + $this->product_helper = $product_helper; + $this->shortlink_helper = $shortlink_helper; + } + + /** + * Returns the conditionals based on which this loadable should be active. + * + * @return array + */ + public static function get_conditionals() { + return [ Admin_Conditional::class ]; + } + + /** + * Initializes the integration. + * + * This is the place to register hooks and filters. + * + * @return void + */ + public function register_hooks() { + if ( \apply_filters( 'wpseo_new_dashboard', false ) ) { + // Add page. + \add_filter( 'wpseo_submenu_pages', [ $this, 'add_page' ] ); + + // Are we on the dashboard page? + if ( $this->current_page_helper->get_current_yoast_seo_page() === self::PAGE ) { + \add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_assets' ] ); + } + } + } + + /** + * Adds the page. + * + * @param array> $pages The pages. + * + * @return array> The pages. + */ + public function add_page( $pages ) { + \array_splice( + $pages, + 3, + 0, + [ + [ + 'wpseo_dashboard', + '', + \__( 'Dashboard New', 'wordpress-seo' ), + 'wpseo_manage_options', + self::PAGE, + [ $this, 'display_page' ], + ], + ] + ); + + return $pages; + } + + /** + * Displays the page. + * + * @return void + */ + public function display_page() { + echo '
'; + } + + /** + * Enqueues the assets. + * + * @return void + */ + public function enqueue_assets() { + // Remove the emoji script as it is incompatible with both React and any contenteditable fields. + \remove_action( 'admin_print_scripts', 'print_emoji_detection_script' ); + \wp_enqueue_media(); + $this->asset_manager->enqueue_script( 'new-dashboard' ); + $this->asset_manager->enqueue_style( 'new-dashboard' ); + $this->asset_manager->localize_script( 'dashboard', 'wpseoScriptData', $this->get_script_data() ); + } + + /** + * Creates the script data. + * + * @return array>> The script data. + */ + public function get_script_data() { + return [ + 'preferences' => [ + 'isPremium' => $this->product_helper->is_premium(), + 'isRtl' => \is_rtl(), + 'pluginUrl' => \plugins_url( '', \WPSEO_FILE ), + 'upsellSettings' => [ + 'actionId' => 'load-nfd-ctb', + 'premiumCtbId' => 'f6a84663-465f-4cb5-8ba5-f7a6d72224b2', + ], + ], + 'linkParams' => $this->shortlink_helper->get_query_params(), + ]; + } +} diff --git a/tests/Unit/Dashboard/User_Interface/General_Page_Integration_Test.php b/tests/Unit/Dashboard/User_Interface/General_Page_Integration_Test.php new file mode 100644 index 00000000000..b2e0789068a --- /dev/null +++ b/tests/Unit/Dashboard/User_Interface/General_Page_Integration_Test.php @@ -0,0 +1,296 @@ +stubTranslationFunctions(); + + $this->asset_manager = Mockery::mock( WPSEO_Admin_Asset_Manager::class ); + $this->current_page_helper = Mockery::mock( Current_Page_Helper::class ); + $this->product_helper = Mockery::mock( Product_Helper::class ); + $this->shortlink_helper = Mockery::mock( Short_Link_Helper::class ); + + $this->instance = new General_Page_Integration( + $this->asset_manager, + $this->current_page_helper, + $this->product_helper, + $this->shortlink_helper + ); + } + + /** + * Tests __construct method. + * + * @covers ::__construct + * + * @return void + */ + public function test_construct() { + $this->assertInstanceOf( + Academy_Integration::class, + new Academy_Integration( + $this->asset_manager, + $this->current_page_helper, + $this->product_helper, + $this->shortlink_helper + ) + ); + } + + /** + * Tests the retrieval of the conditionals. + * + * @covers ::get_conditionals + * + * @return void + */ + public function test_get_conditionals() { + $this->assertEquals( + [ + Admin_Conditional::class, + ], + General_Page_Integration::get_conditionals() + ); + } + + /** + * Provider for test_register_hooks + * + * @return array> + */ + public static function register_hooks_provider() { + return [ + 'Not on dashboard' => [ + 'current_page' => 'not dashboard', + 'action_times' => 0, + ], + 'On dashboard page' => [ + 'current_page' => 'wpseo_page_dashboard_new', + 'action_times' => 1, + ], + ]; + } + + /** + * Tests the registration of the hooks. + * + * @covers ::register_hooks + * + * @dataProvider register_hooks_provider + * + * @param string $current_page The current page. + * @param int $action_times The number of times the action should be called. + * + * @return void + */ + public function test_register_hooks_on_dashboard_page( $current_page, $action_times ) { + + Monkey\Functions\expect( 'add_filter' ) + ->with( 'wpseo_submenu_page', [ $this->instance, 'add_page' ] ) + ->once(); + + Monkey\Functions\expect( 'apply_filters' ) + ->with( 'wpseo_new_dashboard', false ) + ->once()->andReturnTrue(); + + $this->current_page_helper + ->expects( 'get_current_yoast_seo_page' ) + ->once() + ->andReturn( $current_page ); + + Monkey\Functions\expect( 'add_action' ) + ->with( 'admin_enqueue_scripts', [ $this->instance, 'enqueue_assets' ] ) + ->times( $action_times ); + + $this->instance->register_hooks(); + } + + /** + * Tests the addition of the page to the submenu. + * + * @covers ::add_page + * + * @return void + */ + public function test_add_page() { + $pages = $this->instance->add_page( + [ + [ 'page1', '', 'Page 1', 'manage_options', 'page1', [ $this, 'display_page' ] ], + [ 'page2', '', 'Page 2', 'manage_options', 'page2', [ $this, 'display_page' ] ], + [ 'page3', '', 'Page 3', 'manage_options', 'page3', [ $this, 'display_page' ] ], + ] + ); + + // Assert that the new page was added at index 3. + $this->assertEquals( 'wpseo_dashboard', $pages[3][0] ); + $this->assertEquals( '', $pages[3][1] ); + $this->assertEquals( 'Dashboard New', $pages[3][2] ); + $this->assertEquals( 'wpseo_manage_options', $pages[3][3] ); + $this->assertEquals( 'wpseo_page_dashboard_new', $pages[3][4] ); + $this->assertEquals( [ $this->instance, 'display_page' ], $pages[3][5] ); + } + + /** + * Test display_page + * + * @covers ::display_page + * + * @return void + */ + public function test_display_page() { + $this->expectOutputString( '
' ); + $this->instance->display_page(); + } + + /** + * Test enqueue_assets + * + * @covers ::enqueue_assets + * @covers ::get_script_data + * + * @return void + */ + public function test_enqueue_assets() { + Monkey\Functions\expect( 'remove_action' ) + ->with( 'admin_print_scripts', 'print_emoji_detection_script' ) + ->once(); + + Monkey\Functions\expect( 'wp_enqueue_media' )->once(); + + $this->asset_manager + ->expects( 'enqueue_script' ) + ->with( 'new-dashboard' ) + ->once(); + + $this->asset_manager + ->expects( 'enqueue_style' ) + ->with( 'new-dashboard' ) + ->once(); + + $this->asset_manager + ->expects( 'localize_script' ) + ->once(); + + $this->expect_get_script_data(); + + $this->instance->enqueue_assets(); + } + + /** + * Expectations for get_script_data. + * + * @return array> The expected data. + */ + public function expect_get_script_data() { + $link_params = [ + 'php_version' => '8.1', + 'platform' => 'wordpress', + 'platform_version' => '6.2', + 'software' => 'free', + 'software_version' => '20.6-RC2', + 'days_active' => '6-30', + 'user_language' => 'en_US', + ]; + + $this->product_helper + ->expects( 'is_premium' ) + ->once() + ->andReturn( false ); + + Monkey\Functions\expect( 'is_rtl' )->once()->andReturn( false ); + + Monkey\Functions\expect( 'plugins_url' )->once()->andReturn( 'http://basic.wordpress.test/wp-content/worspress-seo' ); + + $this->shortlink_helper + ->expects( 'get_query_params' ) + ->once() + ->andReturn( $link_params ); + + return $link_params; + } + + /** + * Test for get_script_data that is used in enqueue_assets. + * + * @covers ::get_script_data + * + * @return void + */ + public function test_get_script_data() { + $link_params = $this->expect_get_script_data(); + $expected = [ + 'preferences' => [ + 'isPremium' => false, + 'isRtl' => false, + 'pluginUrl' => 'http://basic.wordpress.test/wp-content/worspress-seo', + 'upsellSettings' => [ + 'actionId' => 'load-nfd-ctb', + 'premiumCtbId' => 'f6a84663-465f-4cb5-8ba5-f7a6d72224b2', + ], + ], + 'linkParams' => $link_params, + ]; + + $this->assertSame( $expected, $this->instance->get_script_data() ); + } +}