diff --git a/packages/js/jest.config.js b/packages/js/jest.config.js index fdc7ab7349c..caafa73f7fe 100644 --- a/packages/js/jest.config.js +++ b/packages/js/jest.config.js @@ -3,6 +3,7 @@ module.exports = { setupFilesAfterEnv: [ "/tests/setupTests.js" ], testPathIgnorePatterns: [ "/tests/__mocks__/", + "/tests/dashboard/__mocks__/", "/tests/containers/mockSelectors.js", "/tests/helpers/factory.js", "/tests/setupTests.js", diff --git a/packages/js/src/dashboard/components/dashboard.js b/packages/js/src/dashboard/components/dashboard.js index a1b1935a457..19cab17270b 100644 --- a/packages/js/src/dashboard/components/dashboard.js +++ b/packages/js/src/dashboard/components/dashboard.js @@ -1,65 +1,51 @@ -import { Scores } from "../scores/components/scores"; +import { useCallback, useState } from "@wordpress/element"; import { PageTitle } from "./page-title"; -import { SiteKitSetupWidget } from "./site-kit-setup-widget"; -import { get } from "lodash"; -import { useCallback } from "@wordpress/element"; -import { useToggleState } from "@yoast/ui-library"; -import { useSelect } from "@wordpress/data"; /** * @type {import("../index").ContentType} ContentType * @type {import("../index").Features} Features * @type {import("../index").Endpoints} Endpoints * @type {import("../index").Links} Links + * @type {import("../index").WidgetType} WidgetType + * @type {import("../index").WidgetInstance} WidgetInstance + * @type {import("../services/widget-factory").WidgetFactory} WidgetFactory */ /** + * @param {WidgetType} type The widget type. + * @returns {WidgetInstance} The widget instance. + */ +const prepareWidgetInstance = ( type ) => { + return { id: `widget--${ type }__${ Date.now() }`, type }; +}; + +/** + * @param {WidgetFactory} widgetFactory The widget factory. + * @param {WidgetType[]} initialWidgets The initial widgets. * @param {ContentType[]} contentTypes The content types. * @param {string} userName The user name. * @param {Features} features Whether features are enabled. - * @param {Endpoints} endpoints The endpoints. - * @param {Object} headers The headers for the score requests. * @param {Links} links The links. * * @returns {JSX.Element} The element. */ -// The complexity is cause by the google site kit feature flag which is temporary. -// eslint-disable-next-line complexity -export const Dashboard = ( { contentTypes, userName, features, endpoints, headers, links } ) => { - const siteKitConfiguration = get( window, "wpseoScriptData.dashboard.siteKitConfiguration", { - isInstalled: false, - isActive: false, - isSetupCompleted: false, - isConnected: false, - installUrl: "", - activateUrl: "", - setupUrl: "", - isFeatureEnabled: false, - } ); - const [ showGoogleSiteKit, , , , setRemoveGoogleSiteKit ] = useToggleState( true ); - const learnMorelink = useSelect( select => select( "@yoast/general" ).selectLink( "https://yoa.st/google-site-kit-learn-more" ), [] ); - const handleRemovePermanently = useCallback( ()=>{ - /* eslint-disable-next-line */ - // TODO: Implement the remove permanently functionality. - setRemoveGoogleSiteKit(); - }, [ setRemoveGoogleSiteKit ] ); +export const Dashboard = ( { widgetFactory, initialWidgets = [], userName, features, links } ) => { + const [ widgets, setWidgets ] = useState( () => initialWidgets.map( prepareWidgetInstance ) ); + + // eslint-disable-next-line no-unused-vars + const addWidget = useCallback( ( type ) => { + setWidgets( ( currentWidgets ) => [ ...currentWidgets, prepareWidgetInstance( type ) ] ); + }, [] ); + + const removeWidget = useCallback( ( type ) => { + setWidgets( ( currentWidgets ) => currentWidgets.filter( ( widget ) => widget.type !== type ) ); + }, [] ); return ( <> - { showGoogleSiteKit && siteKitConfiguration.isFeatureEnabled && }
- { features.indexables && features.seoAnalysis && ( - - ) } - { features.indexables && features.readabilityAnalysis && ( - - ) } + { widgets.map( ( widget ) => widgetFactory.createWidget( widget, removeWidget ) ) }
); diff --git a/packages/js/src/dashboard/components/most-popular-table.js b/packages/js/src/dashboard/components/most-popular-table.js deleted file mode 100644 index 07a99120386..00000000000 --- a/packages/js/src/dashboard/components/most-popular-table.js +++ /dev/null @@ -1,46 +0,0 @@ -import { __ } from "@wordpress/i18n"; -import { TableWidget } from "./table-widget"; - -/** - * @type {import("../index").MostPopularContent} Most popular content - */ - -/** - * The top 5 most popular content table component. - * - * @param {[MostPopularContent]} Data The component props. - * - * @returns {JSX.Element} The element. - */ -export const MostPopularTable = ( { data } ) => { - return - - { __( "Landing page", "wordpress-seo" ) } - { __( "Clicks", "wordpress-seo" ) } - { __( "Impressions", "wordpress-seo" ) } - { __( "CTR", "wordpress-seo" ) } - - { __( "Average position", "wordpress-seo" ) } - - -
-
- { __( "SEO score", "wordpress-seo" ) } -
-
-
-
- - { data.map( ( { subject, clicks, impressions, ctr, averagePosition, seoScore }, index ) => ( - - { subject } - { clicks } - { impressions } - { ctr } - { averagePosition } - - - ) ) } - -
; -}; diff --git a/packages/js/src/dashboard/index.js b/packages/js/src/dashboard/index.js index 3a6aca76c67..5c26ba55456 100644 --- a/packages/js/src/dashboard/index.js +++ b/packages/js/src/dashboard/index.js @@ -48,19 +48,48 @@ export { Dashboard } from "./components/dashboard"; * @typedef {Object} Endpoints The endpoints. * @property {string} seoScores The endpoint for SEO scores. * @property {string} readabilityScores The endpoint for readability scores. + * @property {string} topPages The endpoint to get the top pages. */ /** * @typedef {Object} Links The links. * @property {string} dashboardLearnMore The dashboard information link. + * @property {string} errorSupport The support link when errors occur. */ /** - * @typedef {Object} MostPopularContent The most popular content data. + * @typedef {Object} TopPageData The top page data. * @property {string} subject The landing page. * @property {number} clicks The number of clicks. * @property {number} impressions The number of impressions. * @property {number} ctr The click-through rate. * @property {number} position The average position. - * @property {number} seoScore The seo score. + * @property {ScoreType} seoScore The seo score. + */ + +/** + * @typedef {"seoScores"|"readabilityScores"|"topPages"} WidgetType The widget type. + */ + +/** + * @typedef {Object} WidgetTypeInfo The widget info. Should hold what the UI needs to let the user pick a widget. + * @property {WidgetType} type The widget type. + */ + +/** + * @typedef {Object} WidgetInstance The widget instance. Should hold what the UI needs to render the widget. + * @property {string} id The unique identifier. + * @property {WidgetType} type The widget type. + */ + +/** + * @typedef {Object} SiteKitConfiguration The Site Kit configuration. + * @property {boolean} isInstalled Whether Site Kit is installed. + * @property {boolean} isActive Whether Site Kit is active. + * @property {boolean} isSetupCompleted Whether Site Kit is setup. + * @property {boolean} isConnected Whether Site Kit is connected. + * @property {string} installUrl The URL to install Site Kit. + * @property {string} activateUrl The URL to activate Site Kit. + * @property {string} setupUrl The URL to setup Site Kit. + * @property {boolean} isFeatureEnabled Whether the feature is enabled. */ diff --git a/packages/js/src/dashboard/scores/components/scores.js b/packages/js/src/dashboard/scores/components/scores.js index a2d0917a3ad..7b872b31306 100644 --- a/packages/js/src/dashboard/scores/components/scores.js +++ b/packages/js/src/dashboard/scores/components/scores.js @@ -1,7 +1,7 @@ -import { createInterpolateElement, useEffect, useState } from "@wordpress/element"; +import { createInterpolateElement, useCallback, useEffect, useState } from "@wordpress/element"; import { __, sprintf } from "@wordpress/i18n"; -import { Alert, Link, Paper, Title } from "@yoast/ui-library"; -import { useFetch } from "../../fetch/use-fetch"; +import { Alert, Link } from "@yoast/ui-library"; +import { useRemoteData } from "../../services/use-remote-data"; import { SCORE_DESCRIPTIONS } from "../score-meta"; import { ContentTypeFilter } from "./content-type-filter"; import { ScoreContent } from "./score-content"; @@ -15,105 +15,99 @@ import { TermFilter } from "./term-filter"; */ /** - * @param {string|URL} endpoint The endpoint. - * @param {ContentType} contentType The content type. - * @param {Term?} [term] The term. - * @returns {URL} The URL to get scores. + * @param {string} message The message with placeholders. + * @param {JSX.Element} link The link. + * @returns {JSX.Element|string} The message. */ -const createScoresUrl = ( endpoint, contentType, term ) => { - const url = new URL( endpoint ); - - url.searchParams.set( "contentType", contentType.name ); - - if ( contentType.taxonomy?.name && term?.name ) { - url.searchParams.set( "taxonomy", contentType.taxonomy.name ); - url.searchParams.set( "term", term.name ); +const createLinkMessage = ( message, link ) => { + try { + return createInterpolateElement( sprintf( message, "", "" ), { link } ); + } catch ( e ) { + return sprintf( message, "", "" ); } - - return url; }; -// Added dummy space as content to prevent children prop warnings in the console. -const supportLink = ; - -const TimeoutErrorMessage = createInterpolateElement( - sprintf( - /* translators: %1$s and %2$s expand to an opening/closing tag for a link to the support page. */ - __( "A timeout occurred, possibly due to a large number of posts or terms. In case you need further help, please take a look at our %1$sSupport page%2$s.", "wordpress-seo" ), - "", - "" - ), - { - supportLink, - } -); -const OtherErrorMessage = createInterpolateElement( - sprintf( - /* translators: %1$s and %2$s expand to an opening/closing tag for a link to the support page. */ - __( "Something went wrong. In case you need further help, please take a look at our %1$sSupport page%2$s.", "wordpress-seo" ), - "", - "" - ), - { - supportLink, - } -); - /** * @param {Error?} [error] The error. + * @param {string} supportLink The support link. * @returns {JSX.Element} The element. */ -const ErrorAlert = ( { error } ) => { +const ErrorAlert = ( { error, supportLink } ) => { if ( ! error ) { return null; } + + // Added dummy space as content to prevent children prop warnings in the console. + const link = ; + return ( { error?.name === "TimeoutError" - ? TimeoutErrorMessage - : OtherErrorMessage + ? createLinkMessage( + /* translators: %1$s and %2$s expand to an opening/closing tag for a link to the support page. */ + __( "A timeout occurred, possibly due to a large number of posts or terms. In case you need further help, please take a look at our %1$sSupport page%2$s.", "wordpress-seo" ), + link + ) + : createLinkMessage( + /* translators: %1$s and %2$s expand to an opening/closing tag for a link to the support page. */ + __( "Something went wrong. In case you need further help, please take a look at our %1$sSupport page%2$s.", "wordpress-seo" ), + link + ) } ); }; +/** + * @param {ContentType?} [contentType] The selected content type. + * @param {Term?} [term] The selected term. + * @returns {{contentType: string, taxonomy?: string, term?: string}} The score query parameters. + */ +const getScoreQueryParams = ( contentType, term ) => { // eslint-disable-line complexity + const params = { + contentType: contentType?.name, + }; + if ( contentType?.taxonomy?.name && term?.name ) { + params.taxonomy = contentType.taxonomy.name; + params.term = term.name; + } + + return params; +}; + +/** + * @param {?{scores: Score[]}} [data] The data. + * @returns {?Score[]} scores The scores. + */ +const prepareScoreData = ( data ) => data?.scores; + /** * @param {AnalysisType} analysisType The analysis type. Either "seo" or "readability". * @param {ContentType[]} contentTypes The content types. May not be empty. - * @param {string} endpoint The endpoint or base URL. - * @param {Object} headers The headers to send with the request. + * @param {import("../services/data-provider")} dataProvider The data provider. + * @param {import("../services/remote-data-provider")} remoteDataProvider The remote data provider. * @returns {JSX.Element} The element. */ -export const Scores = ( { analysisType, contentTypes, endpoint, headers } ) => { +export const Scores = ( { analysisType, contentTypes, dataProvider, remoteDataProvider } ) => { // eslint-disable-line complexity const [ selectedContentType, setSelectedContentType ] = useState( contentTypes[ 0 ] ); + /** @type {[Term?, function(Term?)]} */ const [ selectedTerm, setSelectedTerm ] = useState(); - const { data: scores, error, isPending } = useFetch( { - dependencies: [ selectedContentType.name, selectedContentType?.taxonomy, selectedTerm?.name ], - url: createScoresUrl( endpoint, selectedContentType, selectedTerm ), - options: { - headers: { - "Content-Type": "application/json", - ...headers, - }, - }, - fetchDelay: 0, - prepareData: ( data ) => data?.scores, - } ); + const getScores = useCallback( ( options ) => remoteDataProvider.fetchJson( + dataProvider.getEndpoint( analysisType + "Scores" ), + getScoreQueryParams( selectedContentType, selectedTerm ), + options + ), [ dataProvider, analysisType, selectedContentType, selectedTerm ] ); + + const { data: scores, error, isPending } = useRemoteData( getScores, prepareScoreData ); useEffect( () => { // Reset the selected term when the selected content type changes. setSelectedTerm( undefined ); // eslint-disable-line no-undefined - }, [ selectedContentType.name ] ); + }, [ selectedContentType?.name ] ); return ( - - - { analysisType === "readability" - ? __( "Readability scores", "wordpress-seo" ) - : __( "SEO scores", "wordpress-seo" ) - } - + <>
{ }
- + { ! error && ( { /> ) }
-
+ ); }; diff --git a/packages/js/src/dashboard/services/data-provider.js b/packages/js/src/dashboard/services/data-provider.js new file mode 100644 index 00000000000..6398ab1458e --- /dev/null +++ b/packages/js/src/dashboard/services/data-provider.js @@ -0,0 +1,92 @@ +/** + * @type {import("../index").ContentType} ContentType + * @type {import("../index").Features} Features + * @type {import("../index").Endpoints} Endpoints + * @type {import("../index").Links} Links + * @type {import("../index").TopPageData} + * @type {import("../index").SiteKitConfiguration} SiteKitConfiguration + */ + +/** + * Controls the data. + */ +export class DataProvider { + #contentTypes; + #userName; + #features; + #endpoints; + #headers; + #links; + #siteKitConfiguration; + + /** + * @param {ContentType[]} contentTypes The content types. + * @param {string} userName The user name. + * @param {Features} features Whether features are enabled. + * @param {Endpoints} endpoints The endpoints. + * @param {Object} headers The headers for the WP requests. + * @param {Links} links The links. + * @param {SiteKitConfiguration} siteKitConfiguration The Site Kit configuration. + */ + constructor( { contentTypes, userName, features, endpoints, headers, links, siteKitConfiguration } ) { + this.#contentTypes = contentTypes; + this.#userName = userName; + this.#features = features; + this.#endpoints = endpoints; + this.#headers = headers; + this.#links = links; + this.#siteKitConfiguration = siteKitConfiguration; + } + + /** + * @returns {ContentType[]} The content types. + */ + getContentTypes() { + return this.#contentTypes; + } + + /** + * @returns {string} The user name. + */ + getUserName() { + return this.#userName; + } + + /** + * @param {string} feature The feature to check. + * @returns {boolean} Whether the feature is enabled. + */ + hasFeature( feature ) { + return this.#features?.[ feature ] === true; + } + + /** + * @param {string} id The identifier. + * @returns {?string} The endpoint, if found. + */ + getEndpoint( id ) { + return this.#endpoints?.[ id ]; + } + + /** + * @returns {Object} The headers for making requests to the endpoints. + */ + getHeaders() { + return this.#headers; + } + + /** + * @param {string} id The identifier. + * @returns {?string} The link, if found. + */ + getLink( id ) { + return this.#links?.[ id ]; + } + + /** + * @returns {SiteKitConfiguration} The site kit configuration data. + */ + getSiteKitConfiguration() { + return this.#siteKitConfiguration; + } +} diff --git a/packages/js/src/dashboard/services/remote-data-provider.js b/packages/js/src/dashboard/services/remote-data-provider.js new file mode 100644 index 00000000000..26bae9d29a3 --- /dev/null +++ b/packages/js/src/dashboard/services/remote-data-provider.js @@ -0,0 +1,47 @@ +import { defaultsDeep } from "lodash"; +import { fetchJson } from "../fetch/fetch-json"; + +/** + * Provides the mechanism to fetch data from a remote source. + */ +export class RemoteDataProvider { + #options; + + /** + * @param {RequestInit} options The fetch options. + * @throws {TypeError} If the baseUrl is invalid. + */ + constructor( options ) { + this.#options = options; + } + + /** + * @param {string|URL} endpoint The endpoint. + * @param {Object} [params] The query parameters. + * @throws {TypeError} If the URL is invalid. + * @link https://developer.mozilla.org/en-US/docs/Web/API/URL + * @returns {URL} The URL. + */ + getUrl( endpoint, params ) { + const url = new URL( endpoint ); + + for ( const [ key, value ] of Object.entries( params ) ) { + url.searchParams.append( key, value ); + } + + return url; + } + + /** + * @param {string|URL} endpoint The endpoint. + * @param {Object} [params] The query parameters. + * @param {RequestInit} [options] The request options. + * @returns {Promise} The promise of a result, or an error. + */ + async fetchJson( endpoint, params, options ) { + return fetchJson( + this.getUrl( endpoint, params ), + defaultsDeep( options, this.#options, { headers: { "Content-Type": "application/json" } } ) + ); + } +} diff --git a/packages/js/src/dashboard/services/use-remote-data.js b/packages/js/src/dashboard/services/use-remote-data.js new file mode 100644 index 00000000000..d696ca89043 --- /dev/null +++ b/packages/js/src/dashboard/services/use-remote-data.js @@ -0,0 +1,57 @@ +import { createSlice } from "@reduxjs/toolkit"; +import { useEffect, useReducer, useRef } from "@wordpress/element"; +import { identity } from "lodash"; + +const slice = createSlice( { + name: "data", + initialState: { + data: undefined, // eslint-disable-line no-undefined + error: undefined, // eslint-disable-line no-undefined + isPending: true, + }, + reducers: { + setData( state, action ) { + state.data = action.payload; + state.error = undefined; // eslint-disable-line no-undefined + state.isPending = false; + }, + setError( state, action ) { + state.error = action.payload; + state.isPending = false; + }, + setIsPending( state, action ) { + state.isPending = Boolean( action.payload ); + }, + }, +} ); + +/** + * @param {function(RequestInit): any} doFetch The fetch function. + * @param {function(any): any} prepareData Process data. + * @returns {{data: any, error: Error, isPending: boolean}} The data state. + */ +export const useRemoteData = ( doFetch, prepareData = identity ) => { + const [ state, dispatch ] = useReducer( slice.reducer, {}, slice.getInitialState ); + /** @type {MutableRefObject} */ + const controller = useRef(); + + useEffect( () => { + // Abort any ongoing request first. + controller.current?.abort(); + controller.current = new AbortController(); + + dispatch( slice.actions.setIsPending( true ) ); + doFetch( { signal: controller.current?.signal } ) + .then( ( response ) => dispatch( slice.actions.setData( prepareData( response ) ) ) ) + .catch( ( e ) => { + // Ignore abort errors, because they are expected and not to be reflected in the UI. + if ( e?.name !== "AbortError" ) { + dispatch( slice.actions.setError( e ) ); + } + } ); + + return () => controller.current?.abort(); + }, [ doFetch ] ); + + return state; +}; diff --git a/packages/js/src/dashboard/services/widget-factory.js b/packages/js/src/dashboard/services/widget-factory.js new file mode 100644 index 00000000000..aa2cc4a0289 --- /dev/null +++ b/packages/js/src/dashboard/services/widget-factory.js @@ -0,0 +1,64 @@ +/* eslint-disable complexity */ +import { ScoreWidget } from "../widgets/score-widget"; +import { SiteKitSetupWidget } from "../widgets/site-kit-setup-widget"; +import { TopPagesWidget } from "../widgets/top-pages-widget"; + +/** + * @type {import("../index").WidgetType} WidgetType + * @type {import("../index").WidgetTypeInfo} WidgetTypeInfo + */ + +/** + * Controls how to create a widget. + */ +export class WidgetFactory { + #dataProvider; + #remoteDataProvider; + + /** + * @param {import("./data-provider").DataProvider} dataProvider + * @param {import("./remote-data-provider").RemoteDataProvider} remoteDataProvider + */ + constructor( dataProvider, remoteDataProvider ) { + this.#dataProvider = dataProvider; + this.#remoteDataProvider = remoteDataProvider; + } + + /** + * @returns {WidgetTypeInfo[]} + */ + static get widgetTypes() { + return [ + { type: "seoScores" }, + { type: "readabilityScores" }, + { type: "topPages" }, + { type: "siteKitSetup" }, + ]; + } + + /** + * @param {WidgetInstance} widget The widget to create. + * @param {function} onRemove The remove handler. + * @returns {JSX.Element|null} The widget or null. + */ + createWidget( widget, onRemove ) { + switch ( widget.type ) { + case "seoScores": + if ( ! ( this.#dataProvider.hasFeature( "indexables" ) && this.#dataProvider.hasFeature( "seoAnalysis" ) ) ) { + return null; + } + return ; + case "readabilityScores": + if ( ! ( this.#dataProvider.hasFeature( "indexables" ) && this.#dataProvider.hasFeature( "readabilityAnalysis" ) ) ) { + return null; + } + return ; + case "topPages": + return ; + case "siteKitSetup": + return ; + default: + return null; + } + } +} diff --git a/packages/js/src/dashboard/widgets/score-widget.js b/packages/js/src/dashboard/widgets/score-widget.js new file mode 100644 index 00000000000..bfb3ba99c30 --- /dev/null +++ b/packages/js/src/dashboard/widgets/score-widget.js @@ -0,0 +1,41 @@ +import { useEffect, useState } from "@wordpress/element"; +import { __ } from "@wordpress/i18n"; +import { Scores } from "../scores/components/scores"; +import { Widget } from "./widget"; + +/** + * @type {import("../index").ContentType} ContentType + * @type {import("../index").AnalysisType} AnalysisType + */ + +/** + * @param {AnalysisType} analysisType The analysis type. + * @param {DataProvider} dataProvider The data provider. + * @param {RemoteDataProvider} remoteDataProvider The remote data provider. + * @returns {?JSX.Element} The element. + */ +export const ScoreWidget = ( { analysisType, dataProvider, remoteDataProvider } ) => { + const [ contentTypes, setContentTypes ] = useState( () => dataProvider.getContentTypes() ); + + useEffect( () => { + setContentTypes( dataProvider.getContentTypes() ); + }, [ dataProvider ] ); + + if ( ! contentTypes?.length ) { + return null; + } + + return ( + + + + ); +}; diff --git a/packages/js/src/dashboard/components/site-kit-setup-widget.js b/packages/js/src/dashboard/widgets/site-kit-setup-widget.js similarity index 84% rename from packages/js/src/dashboard/components/site-kit-setup-widget.js rename to packages/js/src/dashboard/widgets/site-kit-setup-widget.js index 8452807de78..d99e84f1b71 100644 --- a/packages/js/src/dashboard/components/site-kit-setup-widget.js +++ b/packages/js/src/dashboard/widgets/site-kit-setup-widget.js @@ -3,6 +3,7 @@ import { ReactComponent as YoastConnectSiteKit } from "../../../images/yoast-con import { __ } from "@wordpress/i18n"; import { CheckCircleIcon } from "@heroicons/react/solid"; import { ArrowRightIcon, XIcon, TrashIcon } from "@heroicons/react/outline"; +import { useCallback } from "@wordpress/element"; const steps = [ __( "INSTALL", "wordpress-seo" ), @@ -14,31 +15,33 @@ const steps = [ /** * The google site kit connection guide widget. * - * @param {boolean} isInstalled Whether the plugin is installed. - * @param {boolean} isActive Whether the feature is active. - * @param {boolean} isSetupCompleted Whether the setup is complete. - * @param {boolean} isConnected Whether the connection is active. - * @param {string} installUrl The URL to install Site Kit. - * @param {string} activateUrl The URL to activate Site Kit. - * @param {string} setupUrl The URL to setup Site Kit. * @param {function} onRemove The function to call when the widget is removed. - * @param {function} onRemovePermanently The function to call when the widget is removed permanently. - * @param {string} learnMorelink The URL to learn more about the feature. + * @param {DataProvider} dataProvider The data provider. * * @returns {JSX.Element} The widget. */ export const SiteKitSetupWidget = ( { - installUrl, - activateUrl, - setupUrl, - isConnected, - isActive, - isSetupCompleted, - isInstalled, onRemove, - onRemovePermanently, - learnMorelink, + dataProvider, } ) => { + const { + installUrl, + activateUrl, + setupUrl, + isConnected, + isActive, + isSetupCompleted, + isInstalled } = dataProvider.getSiteKitConfiguration(); + const learnMorelink = dataProvider.getLink( "siteKitLearnMorelink" ); + const handleOnRemove = useCallback( () => { + onRemove( "siteKitSetup" ); + }, [ onRemove ] ); + + const onRemovePermanently = useCallback( () => { + // Implement the remove permanently functionality. + handleOnRemove(); + }, [ handleOnRemove ] ); + const stepsStatuses = [ isInstalled, isActive, isSetupCompleted, isConnected ]; let currentStep = stepsStatuses.findIndex( status => ! status ); @@ -69,13 +72,13 @@ export const SiteKitSetupWidget = ( { }, ]; - return + return { __( "Remove until next visit", "wordpress-seo" ) } diff --git a/packages/js/src/dashboard/components/table-widget.js b/packages/js/src/dashboard/widgets/table-widget.js similarity index 71% rename from packages/js/src/dashboard/components/table-widget.js rename to packages/js/src/dashboard/widgets/table-widget.js index 30893a90a4b..29e749e32a6 100644 --- a/packages/js/src/dashboard/components/table-widget.js +++ b/packages/js/src/dashboard/widgets/table-widget.js @@ -1,19 +1,22 @@ +import { Table } from "@yoast/ui-library"; import classNames from "classnames"; -import { Paper, Table, Title } from "@yoast/ui-library"; import { SCORE_META } from "../scores/score-meta"; +import { Widget } from "./widget"; + +/** + * @type {import("../index").ScoreType} ScoreType + */ /** * The score bullet component. * - * @param {string} score The score. + * @param {ScoreType} score The score. * @returns {JSX.Element} The element. */ const ScoreBullet = ( { score } ) => ( -
-
- - { SCORE_META[ score ].label } - +
+
+ { SCORE_META[ score ].label }
); @@ -58,16 +61,13 @@ const TableRow = ( { children, index } ) => { */ export const TableWidget = ( { title, children } ) => { return ( - - - { title } - +
{ children }
-
+ ); }; @@ -77,4 +77,3 @@ TableWidget.ScoreBullet = ScoreBullet; TableWidget.Cell = Table.Cell; TableWidget.Header = Table.Header; TableWidget.Body = Table.Body; - diff --git a/packages/js/src/dashboard/widgets/top-pages-widget.js b/packages/js/src/dashboard/widgets/top-pages-widget.js new file mode 100644 index 00000000000..38982205771 --- /dev/null +++ b/packages/js/src/dashboard/widgets/top-pages-widget.js @@ -0,0 +1,110 @@ +import { useCallback } from "@wordpress/element"; +import { __ } from "@wordpress/i18n"; +import { Alert, SkeletonLoader } from "@yoast/ui-library"; +import { useRemoteData } from "../services/use-remote-data"; +import { TableWidget } from "./table-widget"; +import { Widget } from "./widget"; + +/** + * @type {import("../index").TopPageData} TopPageData + */ + +/** @type {string} */ +const TITLE = __( "Top 5 most popular content", "wordpress-seo" ); + +/** + * @param {number} index The index. + * @returns {JSX.Element} The element. + */ +const TopPagesSkeletonLoaderRow = ( { index } ) => ( + + https://example.com/page + 10 + 100 + 0.12 + 12.34 + +
+ +
+
+
+); + +/** + * @param {TopPageData[]} data The data. + * @param {JSX.Element} [children] The children. Use this to override the data rendering. + * @returns {JSX.Element} The element. + */ +const TopPagesTable = ( { data, children } ) => { + return + + { __( "Landing page", "wordpress-seo" ) } + { __( "Clicks", "wordpress-seo" ) } + { __( "Impressions", "wordpress-seo" ) } + { __( "CTR", "wordpress-seo" ) } + { __( "Average position", "wordpress-seo" ) } + { __( "SEO score", "wordpress-seo" ) } + + + { children || data.map( ( { subject, clicks, impressions, ctr, position, seoScore }, index ) => ( + + { subject } + { clicks } + { impressions } + { ctr } + { position } + + + ) ) } + + ; +}; + +/** + * @param {import("../services/data-provider")} dataProvider The data provider. + * @param {import("../services/remote-data-provider")} remoteDataProvider The remote data provider. + * @param {number} [limit=5] The limit. + * @returns {JSX.Element} The element. + */ +export const TopPagesWidget = ( { dataProvider, remoteDataProvider, limit = 5 } ) => { + const getTopPages = useCallback( ( options ) => { + return remoteDataProvider.fetchJson( dataProvider.getEndpoint( "topPages" ), { limit: limit.toString( 10 ) }, options ); + }, [ dataProvider, limit ] ); + + const { data, error, isPending } = useRemoteData( getTopPages ); + + if ( isPending ) { + return ( + + { Array.from( { length: limit }, ( _, index ) => ( + + ) ) } + + ); + } + + if ( error ) { + return ( + + + { error.message } + + + ); + } + + if ( ! data || data.length === 0 ) { + return ( + +

+ { __( "No data to display: Your site hasn't received any visitors yet.", "wordpress-seo" ) } +

+
+ ); + } + + return ( + + ); +}; diff --git a/packages/js/src/dashboard/widgets/widget.js b/packages/js/src/dashboard/widgets/widget.js new file mode 100644 index 00000000000..dd280cc12df --- /dev/null +++ b/packages/js/src/dashboard/widgets/widget.js @@ -0,0 +1,26 @@ +import { Paper, Title } from "@yoast/ui-library"; +import classNames from "classnames"; + +const WidgetTitle = ( { children, ...props } ) => ( + + { children } + +); +WidgetTitle.displayName = "Widget.Title"; + +/** + * @param {string} [className] The class name. + * @param {string} [title] The title in an H2. + * @param {JSX.Element} children The content. + * @returns {JSX.Element} The element. + */ +export const Widget = ( { className = "yst-paper__content", title, children } ) => { + return ( + + { title && { title } } + { children } + + ); +}; + +Widget.Title = WidgetTitle; diff --git a/packages/js/src/general/initialize.js b/packages/js/src/general/initialize.js index 16ffb9e81e2..c6ad9d843d3 100644 --- a/packages/js/src/general/initialize.js +++ b/packages/js/src/general/initialize.js @@ -6,8 +6,10 @@ import { Root } from "@yoast/ui-library"; import { get } from "lodash"; import { createHashRouter, createRoutesFromElements, Navigate, Route, RouterProvider } from "react-router-dom"; import { Dashboard } from "../dashboard"; -import { LINK_PARAMS_NAME } from "../shared-admin/store"; -import { ADMIN_NOTICES_NAME } from "./store/admin-notices"; +import { DataProvider } from "../dashboard/services/data-provider"; +import { RemoteDataProvider } from "../dashboard/services/remote-data-provider"; +import { WidgetFactory } from "../dashboard/services/widget-factory"; +import { ADMIN_URL_NAME, LINK_PARAMS_NAME } from "../shared-admin/store"; import App from "./app"; import { RouteErrorFallback } from "./components"; import { ConnectedPremiumUpsellList } from "./components/connected-premium-upsell-list"; @@ -15,6 +17,7 @@ import { SidebarLayout } from "./components/sidebar-layout"; import { STORE_NAME } from "./constants"; import { AlertCenter, FirstTimeConfiguration, ROUTES } from "./routes"; import registerStore from "./store"; +import { ADMIN_NOTICES_NAME } from "./store/admin-notices"; import { ALERT_CENTER_NAME } from "./store/alert-center"; /** @@ -31,6 +34,7 @@ domReady( () => { } registerStore( { initialState: { + [ ADMIN_URL_NAME ]: get( window, "wpseoScriptData.adminUrl", "" ), [ LINK_PARAMS_NAME ]: get( window, "wpseoScriptData.linkParams", {} ), [ ALERT_CENTER_NAME ]: { alerts: get( window, "wpseoScriptData.alerts", [] ) }, currentPromotions: { promotions: get( window, "wpseoScriptData.currentPromotions", [] ) }, @@ -56,17 +60,48 @@ domReady( () => { const endpoints = { seoScores: get( window, "wpseoScriptData.dashboard.endpoints.seoScores", "" ), readabilityScores: get( window, "wpseoScriptData.dashboard.endpoints.readabilityScores", "" ), + topPages: get( window, "wpseoScriptData.dashboard.endpoints.topPageResults", "" ), }; /** @type {Object} */ const headers = { "X-Wp-Nonce": get( window, "wpseoScriptData.dashboard.nonce", "" ), }; - /** @type {{dashboardLearnMore: string}} */ + /** @type {Links} */ const links = { dashboardLearnMore: select( STORE_NAME ).selectLink( "https://yoa.st/dashboard-learn-more" ), + errorSupport: select( STORE_NAME ).selectAdminLink( "?page=wpseo_page_support" ), + siteKitLearnMorelink: select( STORE_NAME ).selectLink( "https://yoa.st/google-site-kit-learn-more" ), }; + const siteKitConfiguration = get( window, "wpseoScriptData.dashboard.siteKitConfiguration", { + isInstalled: false, + isActive: false, + isSetupCompleted: false, + isConnected: false, + installUrl: "", + activateUrl: "", + setupUrl: "", + isFeatureEnabled: false, + } ); + + const remoteDataProvider = new RemoteDataProvider( { headers } ); + const dataProvider = new DataProvider( { contentTypes, userName, features, endpoints, headers, links, siteKitConfiguration } ); + const widgetFactory = new WidgetFactory( dataProvider, remoteDataProvider ); + + const initialWidgets = [ "seoScores", "readabilityScores" ]; + + // If site kit feature is enabled, add the site kit setup widget. + if ( siteKitConfiguration.isFeatureEnabled ) { + initialWidgets.push( "siteKitSetup" ); + } + + // If site kit feature is enabled, active and connected: add the top pages widget. + if ( siteKitConfiguration.isFeatureEnabled && siteKitConfiguration.isActive ) { + initialWidgets.push( "topPages" ); + } + + const router = createHashRouter( createRoutesFromElements( } errorElement={ }> @@ -75,11 +110,10 @@ domReady( () => { element={ diff --git a/packages/js/src/general/store/index.js b/packages/js/src/general/store/index.js index 0d277cc2589..bbe827fea69 100644 --- a/packages/js/src/general/store/index.js +++ b/packages/js/src/general/store/index.js @@ -1,13 +1,31 @@ // eslint-disable-next-line import/named import { combineReducers, createReduxStore, register } from "@wordpress/data"; +import { actions, reducers, selectors } from "@yoast/externals/redux"; import { merge } from "lodash"; -import { getInitialLinkParamsState, LINK_PARAMS_NAME, linkParamsActions, linkParamsReducer, linkParamsSelectors } from "../../shared-admin/store"; +import * as dismissedAlertsControls from "../../redux/controls/dismissedAlerts"; +import { + ADMIN_URL_NAME, + adminUrlActions, + adminUrlReducer, + adminUrlSelectors, + getInitialAdminUrlState, + getInitialLinkParamsState, + LINK_PARAMS_NAME, + linkParamsActions, + linkParamsReducer, + linkParamsSelectors, +} from "../../shared-admin/store"; import { STORE_NAME } from "../constants"; +import { ADMIN_NOTICES_NAME, adminNoticesActions, adminNoticesReducer, adminNoticesSelectors, getInitialAdminNoticesState } from "./admin-notices"; +import { + ALERT_CENTER_NAME, + alertCenterActions, + alertCenterControls, + alertCenterReducer, + alertCenterSelectors, + getInitialAlertCenterState, +} from "./alert-center"; import preferences, { createInitialPreferencesState, preferencesActions, preferencesSelectors } from "./preferences"; -import { reducers, selectors, actions } from "@yoast/externals/redux"; -import * as dismissedAlertsControls from "../../redux/controls/dismissedAlerts"; -import { alertCenterReducer, alertCenterActions, alertCenterSelectors, getInitialAlertCenterState, alertCenterControls, ALERT_CENTER_NAME } from "./alert-center"; -import { adminNoticesActions, adminNoticesReducer, adminNoticesSelectors, ADMIN_NOTICES_NAME, getInitialAdminNoticesState } from "./admin-notices"; const { currentPromotions, dismissedAlerts, isPremium } = reducers; const { isAlertDismissed, getIsPremium, isPromotionActive } = selectors; @@ -22,6 +40,7 @@ const { dismissAlert, setCurrentPromotions, setDismissedAlerts, setIsPremium } = const createStore = ( { initialState } ) => { return createReduxStore( STORE_NAME, { actions: { + ...adminUrlActions, ...linkParamsActions, ...preferencesActions, ...alertCenterActions, @@ -32,6 +51,7 @@ const createStore = ( { initialState } ) => { ...adminNoticesActions, }, selectors: { + ...adminUrlSelectors, ...linkParamsSelectors, ...preferencesSelectors, ...alertCenterSelectors, @@ -43,6 +63,7 @@ const createStore = ( { initialState } ) => { initialState: merge( {}, { + [ ADMIN_URL_NAME ]: getInitialAdminUrlState(), [ LINK_PARAMS_NAME ]: getInitialLinkParamsState(), preferences: createInitialPreferencesState(), [ ALERT_CENTER_NAME ]: getInitialAlertCenterState(), @@ -52,6 +73,7 @@ const createStore = ( { initialState } ) => { initialState ), reducer: combineReducers( { + [ ADMIN_URL_NAME ]: adminUrlReducer, [ LINK_PARAMS_NAME ]: linkParamsReducer, preferences, [ ALERT_CENTER_NAME ]: alertCenterReducer, diff --git a/packages/js/tests/dashboard/__mocks__/data-provider.js b/packages/js/tests/dashboard/__mocks__/data-provider.js new file mode 100644 index 00000000000..d8fa5c11d54 --- /dev/null +++ b/packages/js/tests/dashboard/__mocks__/data-provider.js @@ -0,0 +1,76 @@ +import { defaultsDeep } from "lodash"; +import { DataProvider } from "../../../src/dashboard/services/data-provider"; + +/** + * Mock data provider. + */ +export class MockDataProvider extends DataProvider { + /** + * Creates an instance of MockDataProvider. + * + * @param {Object} options - The options to initialize the data provider. + */ + constructor( options = {} ) { + super( defaultsDeep( options, { + contentTypes: [ + { + name: "post", + label: "Posts", + taxonomy: { + name: "category", + label: "Categories", + links: { + search: "https://example.com/categories", + }, + }, + }, + { + name: "page", + label: "Pages", + taxonomy: null, + }, + { + name: "product", + label: "Products", + taxonomy: { + name: "product_cat", + label: "Product categories", + links: { + search: "https://example.com/product_cat", + }, + }, + }, + ], + userName: "Foo", + features: { + indexables: true, + seoAnalysis: true, + readabilityAnalysis: true, + }, + endpoints: { + seoScores: "https://example.com/seo-scores", + readabilityScores: "https://example.com/readability-scores", + topPages: "https://example.com/top-pages", + }, + headers: { + "X-Wp-Nonce": "123", + }, + links: { + dashboardLearnMore: "https://example.com/dashboard-learn-more", + errorSupport: "https://example.com/error-support", + siteKitLearnMorelink: "https://example.com/google-site-kit-learn-more", + }, + siteKitConfiguration: { + isInstalled: false, + isActive: false, + isSetupCompleted: false, + isConnected: false, + installUrl: "https://example.com/install", + activateUrl: "https://example.com/activate", + setupUrl: "https://example.com/isSetup", + isFeatureEnabled: false, + }, + } ) ); + } +} + diff --git a/packages/js/tests/dashboard/__mocks__/remote-data-provider.js b/packages/js/tests/dashboard/__mocks__/remote-data-provider.js new file mode 100644 index 00000000000..518032817f3 --- /dev/null +++ b/packages/js/tests/dashboard/__mocks__/remote-data-provider.js @@ -0,0 +1,9 @@ +import { jest } from "@jest/globals"; +import { RemoteDataProvider } from "../../../src/dashboard/services/remote-data-provider"; + +/** + * Mock remote data provider. + */ +export class MockRemoteDataProvider extends RemoteDataProvider { + fetchJson = jest.fn().mockResolvedValue( [] ); +} diff --git a/packages/js/tests/dashboard/components/site-kit-setup-widget.test.js b/packages/js/tests/dashboard/components/site-kit-setup-widget.test.js deleted file mode 100644 index 5dc691ff5dd..00000000000 --- a/packages/js/tests/dashboard/components/site-kit-setup-widget.test.js +++ /dev/null @@ -1,71 +0,0 @@ -import { render, screen, fireEvent } from "@testing-library/react"; -import { SiteKitSetupWidget } from "../../../src/dashboard/components/site-kit-setup-widget"; - -describe( "SiteKitSetupWidget", () => { - const defaultProps = { - installUrl: "https://example.com/install", - activateUrl: "https://example.com/activate", - setupUrl: "https://example.com/isSetup", - isConnected: false, - isActive: false, - isSetupCompleted: false, - isInstalled: false, - onRemove: jest.fn(), - onRemovePermanently: jest.fn(), - learnMorelink: "https://example.com/learn-more", - }; - - it( "renders the widget with install button", () => { - render( ); - const installLink = screen.getByRole( "link", { name: /Install Site Kit by Google/i } ); - expect( installLink ).toBeInTheDocument(); - expect( installLink ).toHaveAttribute( "href", defaultProps.installUrl ); - } ); - - it( "renders the widget with activate button", () => { - render( ); - const activateLink = screen.getByRole( "link", { name: /Activate Site Kit by Google/i } ); - expect( activateLink ).toBeInTheDocument(); - expect( activateLink ).toHaveAttribute( "href", defaultProps.activateUrl ); - } ); - - it( "renders the widget with setup button", () => { - render( ); - const setupLink = screen.getByRole( "link", { name: /Set up Site Kit by Google/i } ); - expect( setupLink ).toBeInTheDocument(); - expect( setupLink ).toHaveAttribute( "href", defaultProps.setupUrl ); - } ); - - it( "renders the widget with connect button", () => { - render( ); - expect( screen.getByRole( "button", { name: /Connect Site Kit by Google/i } ) ).toBeInTheDocument(); - } ); - - it( "renders the widget with dismiss button when connected", () => { - render( ); - expect( screen.getByRole( "button", { name: /Dismiss/i } ) ).toBeInTheDocument(); - } ); - - it( "opens the menu and calls onRemove when 'Remove until next visit' is clicked", () => { - render( ); - fireEvent.click( screen.getByRole( "button", { name: /Open Site Kit widget dropdown menu/i } ) ); - const removeButton = screen.getByRole( "menuitem", { name: /Remove until next visit/i, type: "button" } ); - fireEvent.click( removeButton ); - expect( defaultProps.onRemove ).toHaveBeenCalled(); - } ); - - it( "opens the menu and calls onRemovePermanently when 'Remove permanently' is clicked", () => { - render( ); - fireEvent.click( screen.getByRole( "button", { name: /Open Site Kit widget dropdown menu/i } ) ); - const removeButton = screen.getByRole( "menuitem", { name: /Remove permanently/i, type: "button" } ); - fireEvent.click( removeButton ); - expect( defaultProps.onRemovePermanently ).toHaveBeenCalled(); - } ); - - it( "renders the widget with learn more link", () => { - render( ); - const learnMoreLink = screen.getByRole( "link", { name: /Learn more/i } ); - expect( learnMoreLink ).toBeInTheDocument(); - expect( learnMoreLink ).toHaveAttribute( "href", defaultProps.learnMorelink ); - } ); -} ); diff --git a/packages/js/tests/dashboard/scores/components/scores.test.js b/packages/js/tests/dashboard/scores/components/scores.test.js index ca9f16428cb..8ffe4a2bfc1 100644 --- a/packages/js/tests/dashboard/scores/components/scores.test.js +++ b/packages/js/tests/dashboard/scores/components/scores.test.js @@ -2,7 +2,9 @@ import { beforeAll, beforeEach, describe, expect, it, jest } from "@jest/globals import { fetchJson } from "../../../../src/dashboard/fetch/fetch-json"; import { TimeoutError } from "../../../../src/dashboard/fetch/timeout-error"; import { Scores } from "../../../../src/dashboard/scores/components/scores"; -import { fireEvent, render, waitFor } from "../../../test-utils"; +import { DataProvider } from "../../../../src/dashboard/services/data-provider"; +import { RemoteDataProvider } from "../../../../src/dashboard/services/remote-data-provider"; +import { act, fireEvent, render, waitFor } from "../../../test-utils"; import categories from "./__data__/categories.json"; import contentTypes from "./__data__/content-types.json"; import productCategories from "./__data__/product_cat.json"; @@ -17,7 +19,11 @@ jest.mock( "react-chartjs-2" ); // Mock fetchJson, providing the data for the tests. jest.mock( "../../../../src/dashboard/fetch/fetch-json" ); + describe( "Scores", () => { + let dataProvider; + let remoteDataProvider; + beforeAll( () => { fetchJson.mockImplementation( ( url ) => { switch ( url.pathname ) { @@ -36,6 +42,17 @@ describe( "Scores", () => { return Promise.resolve( scores ); } } ); + + dataProvider = new DataProvider( { + endpoints: { + seoScores: "https://example.com/seo_scores", + readabilityScores: "https://example.com/readability_scores", + }, + links: { + errorSupport: "admin.php?page=wpseo_page_support", + }, + } ); + remoteDataProvider = new RemoteDataProvider( {} ); } ); beforeEach( () => { @@ -47,13 +64,11 @@ describe( "Scores", () => { ); - // Verify the title is present. - expect( getByRole( "heading", { name: "SEO scores" } ) ).toBeInTheDocument(); - // Verify the filters are present. expect( getByRole( "combobox", { name: "Content type" } ) ).toBeInTheDocument(); expect( getByRole( "combobox", { name: "Categories" } ) ).toBeInTheDocument(); @@ -92,7 +107,11 @@ describe( "Scores", () => { "admin.php?page=wpseo_page_support", + getEndpoint: () => "https://example.com/error", + } } + remoteDataProvider={ remoteDataProvider } /> ); @@ -112,7 +131,11 @@ describe( "Scores", () => { "admin.php?page=wpseo_page_support", + getEndpoint: () => "https://example.com/timeout", + } } + remoteDataProvider={ remoteDataProvider } /> ); @@ -132,7 +155,8 @@ describe( "Scores", () => { ); @@ -149,8 +173,10 @@ describe( "Scores", () => { const pagesOption = getByRole( "option", { name: "Pages" } ); expect( pagesOption ).toBeInTheDocument(); - // Select the "Pages" option. - fireEvent.click( pagesOption ); + await act( () => { + // Select the "Pages" option. + fireEvent.click( pagesOption ); + } ); // Await new fetch call for the scores. await waitFor( () => expect( fetchJson ).toHaveBeenCalledTimes( 3 ) ); @@ -166,13 +192,11 @@ describe( "Scores", () => { ); - // Verify the title is present. - expect( getByRole( "heading", { name: "Readability scores" } ) ).toBeInTheDocument(); - // Await the fetch calls: scores and terms. await waitFor( () => expect( fetchJson ).toHaveBeenCalledTimes( 2 ) ); // Verify the `readability_scores` part. @@ -181,7 +205,9 @@ describe( "Scores", () => { // Select the content type: "Products". fireEvent.click( getByRole( "combobox", { name: "Content type" } ) ); - fireEvent.click( getByRole( "option", { name: "Products" } ) ); + await act( () => { + fireEvent.click( getByRole( "option", { name: "Products" } ) ); + } ); // Await new fetch call for the scores and terms. await waitFor( () => expect( fetchJson ).toHaveBeenCalledTimes( 4 ) ); @@ -191,7 +217,9 @@ describe( "Scores", () => { // Select the product term: "merchandise". fireEvent.click( getByRole( "combobox", { name: "Product categories" } ) ); - fireEvent.click( getByRole( "option", { name: "merchandise" } ) ); + await act( () => { + fireEvent.click( getByRole( "option", { name: "merchandise" } ) ); + } ); // Await new fetch call for the scores. await waitFor( () => expect( fetchJson ).toHaveBeenCalledTimes( 5 ) ); @@ -207,7 +235,8 @@ describe( "Scores", () => { ); @@ -228,7 +257,8 @@ describe( "Scores", () => { ); @@ -251,7 +281,8 @@ describe( "Scores", () => { ); @@ -275,7 +306,8 @@ describe( "Scores", () => { ); @@ -301,7 +333,8 @@ describe( "Scores", () => { ); diff --git a/packages/js/tests/dashboard/services/widget-factory.test.js b/packages/js/tests/dashboard/services/widget-factory.test.js new file mode 100644 index 00000000000..04b938cfd7b --- /dev/null +++ b/packages/js/tests/dashboard/services/widget-factory.test.js @@ -0,0 +1,66 @@ +import { beforeAll, describe, expect, jest } from "@jest/globals"; +import { waitFor } from "@testing-library/react"; +import { WidgetFactory } from "../../../src/dashboard/services/widget-factory"; +import { render } from "../../test-utils"; +import { MockDataProvider } from "../__mocks__/data-provider"; +import { MockRemoteDataProvider } from "../__mocks__/remote-data-provider"; + +// Mock the Chart.js library. Preventing the error: +// > Error: Not implemented: HTMLCanvasElement.prototype.getContext (without installing the canvas npm package). +// Note: this also prevents the canvas from being rendered. +jest.mock( "chart.js" ); +jest.mock( "react-chartjs-2" ); + +describe( "WidgetFactory", () => { + let widgetFactory; + let dataProvider; + let remoteDataProvider; + + beforeAll( () => { + dataProvider = new MockDataProvider(); + remoteDataProvider = new MockRemoteDataProvider( {} ); + widgetFactory = new WidgetFactory( dataProvider, remoteDataProvider ); + } ); + + test.each( [ + [ "seoScores" ], + [ "readabilityScores" ], + [ "topPages" ], + ] )( "should have the widget type: %s", async( type ) => { + expect( WidgetFactory.widgetTypes.map( info => info?.type ) ).toContain( type ); + } ); + + test.each( [ + [ "SEO scores", { id: "seo-scores-widget", type: "seoScores" }, "SEO scores" ], + [ "Readability scores", { id: "readability-scores-widget", type: "readabilityScores" }, "Readability scores" ], + [ "Top pages", { id: "top-pages-widget", type: "topPages" }, "Top 5 most popular content" ], + [ "Unknown", { id: undefined, type: "unknown" }, undefined ], + ] )( "should create a %s widget", async( _, widget, title ) => { + const element = widgetFactory.createWidget( widget, jest.fn() ); + expect( element?.key ).toBe( widget.id ); + const { getByRole } = render( <>{ element } ); + + await waitFor( () => { + // Verify the title is present. + if ( title ) { + expect( getByRole( "heading", { name: title } ) ).toBeInTheDocument(); + } + } ); + } ); + + test.each( [ + [ "SEO scores", { id: "seo-scores-widget", type: "seoScores" } ], + [ "Readability scores", { id: "readability-scores-widget", type: "readabilityScores" } ], + ] )( "should not create the %s widget if the data provider does not have the features", ( _, widget ) => { + dataProvider = new MockDataProvider( { + features: { + indexables: false, + seoAnalysis: false, + readabilityAnalysis: false, + }, + } ); + widgetFactory = new WidgetFactory( dataProvider, remoteDataProvider ); + + expect( widgetFactory.createWidget( widget, jest.fn() ) ).toBeNull(); + } ); +} ); diff --git a/packages/js/tests/dashboard/widgets/site-kit-setup-widget.test.js b/packages/js/tests/dashboard/widgets/site-kit-setup-widget.test.js new file mode 100644 index 00000000000..4a9efb6f71e --- /dev/null +++ b/packages/js/tests/dashboard/widgets/site-kit-setup-widget.test.js @@ -0,0 +1,106 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { MockDataProvider } from "../__mocks__/data-provider"; +import { SiteKitSetupWidget } from "../../../src/dashboard/widgets/site-kit-setup-widget"; + +describe( "SiteKitSetupWidget", () => { + let dataProvider; + const onRemove = jest.fn(); + + const defaultData = { + installUrl: "https://example.com/install", + activateUrl: "https://example.com/activate", + setupUrl: "https://example.com/isSetup", + isInstalled: false, + isActive: false, + isSetupCompleted: false, + isConnected: false, + }; + + it( "renders the widget with install button", () => { + dataProvider = new MockDataProvider(); + render( ); + const installLink = screen.getByRole( "link", { name: /Install Site Kit by Google/i } ); + expect( installLink ).toBeInTheDocument(); + expect( installLink ).toHaveAttribute( "href", defaultData.installUrl ); + } ); + + it( "renders the widget with learn more link", () => { + render( ); + const learnMoreLink = screen.getByRole( "link", { name: /Learn more/i } ); + expect( learnMoreLink ).toBeInTheDocument(); + expect( learnMoreLink ).toHaveAttribute( "href", "https://example.com/google-site-kit-learn-more" ); + } ); + + it( "renders the widget with activate button", () => { + dataProvider = new MockDataProvider( { + siteKitConfiguration: { + ...defaultData, + isInstalled: true, + }, + } ); + render( ); + const activateLink = screen.getByRole( "link", { name: /Activate Site Kit by Google/i } ); + expect( activateLink ).toBeInTheDocument(); + expect( activateLink ).toHaveAttribute( "href", defaultData.activateUrl ); + } ); + + it( "renders the widget with setup button", () => { + dataProvider = new MockDataProvider( { + siteKitConfiguration: { + ...defaultData, + isInstalled: true, + isActive: true, + }, + } ); + render( ); + const setupLink = screen.getByRole( "link", { name: /Set up Site Kit by Google/i } ); + expect( setupLink ).toBeInTheDocument(); + expect( setupLink ).toHaveAttribute( "href", defaultData.setupUrl ); + } ); + + it( "renders the widget with connect button", () => { + dataProvider = new MockDataProvider( { + siteKitConfiguration: { + ...defaultData, + isInstalled: true, + isActive: true, + isSetupCompleted: true, + }, + } ); + render( ); + expect( screen.getByRole( "button", { name: /Connect Site Kit by Google/i } ) ).toBeInTheDocument(); + } ); + + it( "renders the widget with dismiss button when connected", () => { + dataProvider = new MockDataProvider( { + siteKitConfiguration: { + ...defaultData, + isInstalled: true, + isActive: true, + isSetupCompleted: true, + isConnected: true, + }, + } ); + render( ); + const dismissButton = screen.getByRole( "button", { name: /Dismiss/i } ); + expect( dismissButton ).toBeInTheDocument(); + fireEvent.click( dismissButton ); + expect( onRemove ).toHaveBeenCalled(); + } ); + + it( "opens the menu and calls onRemove when 'Remove until next visit' is clicked", () => { + render( ); + fireEvent.click( screen.getByRole( "button", { name: /Open Site Kit widget dropdown menu/i } ) ); + const removeButton = screen.getByRole( "menuitem", { name: /Remove until next visit/i, type: "button" } ); + fireEvent.click( removeButton ); + expect( onRemove ).toHaveBeenCalled(); + } ); + + it( "opens the menu and calls onRemovePermanently when 'Remove permanently' is clicked", () => { + render( ); + fireEvent.click( screen.getByRole( "button", { name: /Open Site Kit widget dropdown menu/i } ) ); + const removeButton = screen.getByRole( "menuitem", { name: /Remove permanently/i, type: "button" } ); + fireEvent.click( removeButton ); + expect( onRemove ).toHaveBeenCalled(); + } ); +} ); diff --git a/packages/js/tests/dashboard/widgets/top-pages-widget.test.js b/packages/js/tests/dashboard/widgets/top-pages-widget.test.js new file mode 100644 index 00000000000..6ad7581e9cd --- /dev/null +++ b/packages/js/tests/dashboard/widgets/top-pages-widget.test.js @@ -0,0 +1,97 @@ +import { beforeAll, beforeEach, describe, expect, it } from "@jest/globals"; +import { forEach } from "lodash"; +import { SCORE_META } from "../../../src/dashboard/scores/score-meta"; +import { TopPagesWidget } from "../../../src/dashboard/widgets/top-pages-widget"; +import { render, waitFor } from "../../test-utils"; +import { MockDataProvider } from "../__mocks__/data-provider"; +import { MockRemoteDataProvider } from "../__mocks__/remote-data-provider"; + +const data = [ + { subject: "https://example.com/page-1", clicks: 100, impressions: 1000, ctr: 10, position: 1, seoScore: "ok" }, + { subject: "https://example.com/page-2", clicks: 101, impressions: 1001, ctr: 11, position: 2, seoScore: "good" }, + { subject: "https://example.com/page-3", clicks: 102, impressions: 1002, ctr: 12, position: 3, seoScore: "bad" }, + { subject: "https://example.com/page-4", clicks: 103, impressions: 1003, ctr: 13, position: 4, seoScore: "notAnalyzed" }, +]; + +describe( "TopPagesWidget", () => { + let dataProvider; + let remoteDataProvider; + + beforeAll( () => { + dataProvider = new MockDataProvider(); + remoteDataProvider = new MockRemoteDataProvider( {} ); + } ); + + beforeEach( () => { + remoteDataProvider.fetchJson.mockClear(); + } ); + + it( "should render the TopPagesWidget component", async() => { + remoteDataProvider.fetchJson.mockResolvedValue( data ); + const { getByRole, getByText } = render( ); + + // Verify the title is present. + await waitFor( () => { + expect( getByRole( "heading", { name: "Top 5 most popular content" } ) ).toBeInTheDocument(); + } ); + + // Verify the table is present. + expect( getByRole( "table" ) ).toBeInTheDocument(); + // Verify rows are present. + forEach( data, ( { subject, clicks, impressions, ctr, position, seoScore } ) => { + expect( getByText( subject ) ).toBeInTheDocument(); + expect( getByText( clicks ) ).toBeInTheDocument(); + expect( getByText( impressions ) ).toBeInTheDocument(); + expect( getByText( ctr ) ).toBeInTheDocument(); + expect( getByText( position ) ).toBeInTheDocument(); + expect( getByText( SCORE_META[ seoScore ].label ) ).toBeInTheDocument(); + } ); + } ); + + it( "should render the TopPagesWidget component without data", async() => { + remoteDataProvider.fetchJson.mockResolvedValue( [] ); + const { getByText } = render( ); + + // Verify no data message is present. + await waitFor( () => { + expect( getByText( "No data to display: Your site hasn't received any visitors yet." ) ).toBeInTheDocument(); + } ); + } ); + + it( "should render the TopPagesWidget component with an error", async() => { + const message = "An error occurred."; + remoteDataProvider.fetchJson.mockRejectedValue( new Error( message ) ); + const { getByText } = render( ); + + await waitFor( () => { + expect( getByText( message ) ).toBeInTheDocument(); + } ); + } ); + + it( "should render the TopPagesWidget component with a pending state", async() => { + // Never resolving promise to ensure it keeps loading. + remoteDataProvider.fetchJson.mockImplementation( () => new Promise( () => {} ) ); + const { getByRole, container } = render( ); + + // Verify the skeleton loader is present. + await waitFor( () => { + expect( getByRole( "table" ) ).toBeInTheDocument(); + } ); + + // Expect limit (1) row with 6 columns = 6 skeleton loaders. + expect( container.getElementsByClassName( "yst-skeleton-loader" ).length ).toBe( 6 ); + } ); +} ); diff --git a/src/dashboard/application/configuration/dashboard-configuration.php b/src/dashboard/application/configuration/dashboard-configuration.php index 9ad97d3c2e0..8b31354bd00 100644 --- a/src/dashboard/application/configuration/dashboard-configuration.php +++ b/src/dashboard/application/configuration/dashboard-configuration.php @@ -4,7 +4,6 @@ // phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong namespace Yoast\WP\SEO\Dashboard\Application\Configuration; -use Yoast\WP\SEO\Conditionals\Google_Site_Kit_Feature_Conditional; use Yoast\WP\SEO\Dashboard\Application\Content_Types\Content_Types_Repository; use Yoast\WP\SEO\Dashboard\Application\Endpoints\Endpoints_Repository; use Yoast\WP\SEO\Dashboard\Infrastructure\Integrations\Site_Kit; @@ -13,7 +12,6 @@ use Yoast\WP\SEO\Editors\Framework\Keyphrase_Analysis; use Yoast\WP\SEO\Editors\Framework\Readability_Analysis; use Yoast\WP\SEO\Helpers\Indexable_Helper; -use Yoast\WP\SEO\Helpers\Options_Helper; use Yoast\WP\SEO\Helpers\User_Helper; /** @@ -64,21 +62,7 @@ class Dashboard_Configuration { private $nonce_repository; /** - * The Google Site Kit conditional. - * - * @var Google_Site_Kit_Feature_Conditional - */ - private $google_site_kit_conditional; - - /** - * The options helper. - * - * @var Options_Helper - */ - private $options_helper; - - /** - * The site kit integration configuration data. + * The Site Kit integration data. * * @var Site_Kit */ @@ -95,9 +79,7 @@ class Dashboard_Configuration { * repository. * @param Endpoints_Repository $endpoints_repository The endpoints repository. * @param Nonce_Repository $nonce_repository The nonce repository. - * @param Google_Site_Kit_Feature_Conditional $google_site_kit_conditional The Google Site Kit conditional. - * @param Options_Helper $options_helper The options helper. - * @param Site_Kit $site_kit_integration_data The site kit integration configuration data. + * @param Site_Kit $site_kit_integration_data The Site Kit integration data. */ public function __construct( Content_Types_Repository $content_types_repository, @@ -106,8 +88,6 @@ public function __construct( Enabled_Analysis_Features_Repository $enabled_analysis_features_repository, Endpoints_Repository $endpoints_repository, Nonce_Repository $nonce_repository, - Google_Site_Kit_Feature_Conditional $google_site_kit_conditional, - Options_Helper $options_helper, Site_Kit $site_kit_integration_data ) { $this->content_types_repository = $content_types_repository; @@ -116,8 +96,6 @@ public function __construct( $this->enabled_analysis_features_repository = $enabled_analysis_features_repository; $this->endpoints_repository = $endpoints_repository; $this->nonce_repository = $nonce_repository; - $this->google_site_kit_conditional = $google_site_kit_conditional; - $this->options_helper = $options_helper; $this->site_kit_integration_data = $site_kit_integration_data; } diff --git a/src/general/user-interface/general-page-integration.php b/src/general/user-interface/general-page-integration.php index 0a119835477..0faead50e19 100644 --- a/src/general/user-interface/general-page-integration.php +++ b/src/general/user-interface/general-page-integration.php @@ -208,6 +208,7 @@ private function get_script_data() { 'premiumCtbId' => 'f6a84663-465f-4cb5-8ba5-f7a6d72224b2', ], ], + 'adminUrl' => \admin_url( 'admin.php' ), 'linkParams' => $this->shortlink_helper->get_query_params(), 'userEditUrl' => \add_query_arg( 'user_id', '{user_id}', \admin_url( 'user-edit.php' ) ), 'alerts' => $this->notification_helper->get_alerts(), diff --git a/tests/Unit/General/User_Interface/General_Page_Integration_Test.php b/tests/Unit/General/User_Interface/General_Page_Integration_Test.php index e40003f3148..46229e9f49d 100644 --- a/tests/Unit/General/User_Interface/General_Page_Integration_Test.php +++ b/tests/Unit/General/User_Interface/General_Page_Integration_Test.php @@ -317,7 +317,7 @@ public function expect_get_script_data() { Monkey\Functions\expect( 'is_rtl' )->once()->andReturn( false ); Monkey\Functions\expect( 'add_query_arg' )->once(); - Monkey\Functions\expect( 'admin_url' )->once(); + Monkey\Functions\expect( 'admin_url' )->twice(); Monkey\Functions\expect( 'plugins_url' ) ->once() ->andReturn( 'http://basic.wordpress.test/wp-content/worspress-seo' );