From a692e505b2d83104c20565b9116cc1116d6998b8 Mon Sep 17 00:00:00 2001 From: Igor <35524806+igorschoester@users.noreply.github.com> Date: Thu, 30 Jan 2025 11:30:01 +0100 Subject: [PATCH 1/4] Fix route naming conflict --- .../endpoints/search-rankings/top-query-endpoint.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dashboard/infrastructure/endpoints/search-rankings/top-query-endpoint.php b/src/dashboard/infrastructure/endpoints/search-rankings/top-query-endpoint.php index 96011d6b4a1..a415db38147 100644 --- a/src/dashboard/infrastructure/endpoints/search-rankings/top-query-endpoint.php +++ b/src/dashboard/infrastructure/endpoints/search-rankings/top-query-endpoint.php @@ -19,7 +19,7 @@ class Top_Query_Endpoint implements Endpoint_Interface { * @return string */ public function get_name(): string { - return 'topPageResults'; + return 'topQueryResults'; } /** From a0717cc44774737d2bbd19d3a48be079bd601f61 Mon Sep 17 00:00:00 2001 From: Igor <35524806+igorschoester@users.noreply.github.com> Date: Thu, 30 Jan 2025 12:55:34 +0100 Subject: [PATCH 2/4] Create services: data provider and widget factory Initial setup for architecture change * move data into data provider * use widget factory to create score widgets --- .../js/src/dashboard/components/dashboard.js | 37 +++++-- packages/js/src/dashboard/index.js | 17 ++- .../src/dashboard/services/data-provider.js | 102 ++++++++++++++++++ .../src/dashboard/services/widget-factory.js | 53 +++++++++ .../js/src/dashboard/widgets/score-widget.js | 31 ++++++ packages/js/src/general/initialize.js | 14 ++- 6 files changed, 238 insertions(+), 16 deletions(-) create mode 100644 packages/js/src/dashboard/services/data-provider.js create mode 100644 packages/js/src/dashboard/services/widget-factory.js create mode 100644 packages/js/src/dashboard/widgets/score-widget.js diff --git a/packages/js/src/dashboard/components/dashboard.js b/packages/js/src/dashboard/components/dashboard.js index 62f58e02edc..d0c1379033d 100644 --- a/packages/js/src/dashboard/components/dashboard.js +++ b/packages/js/src/dashboard/components/dashboard.js @@ -1,4 +1,4 @@ -import { Scores } from "../scores/components/scores"; +import { useCallback, useState } from "@wordpress/element"; import { PageTitle } from "./page-title"; /** @@ -6,28 +6,45 @@ import { PageTitle } from "./page-title"; * @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. */ -export const Dashboard = ( { contentTypes, userName, features, endpoints, headers, links } ) => { +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 ( <>
- { features.indexables && features.seoAnalysis && ( - - ) } - { features.indexables && features.readabilityAnalysis && ( - - ) } + { widgets.map( ( widget ) => widgetFactory.createWidget( widget, removeWidget ) ) }
); diff --git a/packages/js/src/dashboard/index.js b/packages/js/src/dashboard/index.js index 3a6aca76c67..cafa31f5e4c 100644 --- a/packages/js/src/dashboard/index.js +++ b/packages/js/src/dashboard/index.js @@ -62,5 +62,20 @@ export { Dashboard } from "./components/dashboard"; * @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"|"popularContent"} 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. */ 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..7a135134aab --- /dev/null +++ b/packages/js/src/dashboard/services/data-provider.js @@ -0,0 +1,102 @@ +/** + * @type {import("../index").ContentType} ContentType + * @type {import("../index").Features} Features + * @type {import("../index").Endpoints} Endpoints + * @type {import("../index").Links} Links + */ + +export class DataProvider { + #contentTypes; + #userName; + #features; + #endpoints; + #headers; + #links; + + /** + * @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. + * @constructor + */ + constructor( { contentTypes, userName, features, endpoints, headers, links } ) { + this.#contentTypes = contentTypes; + this.#userName = userName; + this.#features = features; + this.#endpoints = endpoints; + this.#headers = headers; + this.#links = links; + } + + async getContentTypes() { + return this.#contentTypes; + } + + async getUserName() { + return this.#userName; + } + + async hasFeature( feature ) { + return this.#features?.[ feature ] === true; + } + + async getEndpoint( endpoint ) { + return this.#endpoints?.[ endpoint ]; + } + + async getHeaders() { + return this.#headers; + } + + async getLink( id ) { + return this.#links?.[ id ]; + } + + /** + * @param {string} endpoint The endpoint. + * @param {number?} limit The number of results to return. + * @throws {TypeError} If the URL is invalid. + * @link https://developer.mozilla.org/en-US/docs/Web/API/URL + * @returns {URL} The URL to get the most popular content. + */ + static #createTopPagesUrl( endpoint, limit ) { + const url = new URL( endpoint ); + + if ( limit ) { + url.searchParams.set( "limit", limit.toString( 10 ) ); + } + + return url; + } + + async getTopPages( limit, signal ) { + try { + const response = await fetch( + DataProvider.#createTopPagesUrl( this.#endpoints.topPages, limit ), + { + method: "GET", + headers: { + "Content-Type": "application/json", + ...this.#headers, + }, + signal, + } + ); + if ( ! response.ok ) { + throw new Error( "Not OK" ); + } + + const data = await response.json(); + if ( ! data || ! Array.isArray( data ) ) { + throw new Error( "Invalid data" ); + } + + return data; + } catch ( error ) { + throw new Error( `Failed to fetch most popular content: ${ error }` ); + } + } +} 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..24b505edf33 --- /dev/null +++ b/packages/js/src/dashboard/services/widget-factory.js @@ -0,0 +1,53 @@ +/* eslint-disable complexity */ +import { ScoreWidget } from "../widgets/score-widget"; + +/** + * @type {import("../index").WidgetType} WidgetType + * @type {import("../index").WidgetTypeInfo} WidgetTypeInfo + */ + +/** + * Controls how to create a widget. + */ +export class WidgetFactory { + #dataProvider; + + /** + * @param {import("./data-provider").DataProvider} dataProvider + */ + constructor( dataProvider ) { + this.#dataProvider = dataProvider; + } + + /** + * @returns {WidgetTypeInfo[]} + */ + static get widgetTypes() { + return [ + { type: "seoScores" }, + { type: "readabilityScores" }, + ]; + } + + /** + * @param {WidgetInstance} widget The widget to create. + * @param {function} onRemove The remove handler. + * @returns {JSX.Element|null} The widget or null. + */ + // eslint-disable-next-line no-unused-vars + createWidget( widget, onRemove ) { + switch ( widget.type ) { + case "seoScores": + // TODO: Should this move into a FeatureWidget, or just into the ScoreWidget itself? + 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 ; + } + } +} 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..3fe73799e8a --- /dev/null +++ b/packages/js/src/dashboard/widgets/score-widget.js @@ -0,0 +1,31 @@ +import { useEffect, useState } from "@wordpress/element"; +import { Scores } from "../scores/components/scores"; + +export const ScoreWidget = ( { analysisType, dataProvider } ) => { + const [ contentTypes, setContentTypes ] = useState( [] ); + const [ endpoint, setEndpoint ] = useState( "" ); + const [ headers, setHeaders ] = useState( {} ); + + useEffect( () => { + // Note: fetching all and setting headers before endpoint or the requests will fail. + Promise.allSettled( [ + dataProvider.getContentTypes(), + dataProvider.getEndpoint( analysisType + "Scores" ), + dataProvider.getHeaders(), + ] ).then( ( [ contentTypes, endpoint, headers ] ) => { + setHeaders( headers.value ); + setEndpoint( endpoint.value ); + setContentTypes( contentTypes.value ); + } ); + }, [ dataProvider ] ); + + // TODO: initial implementation expects data to be there before rendering. + // Rework to handle async contentTypes and endpoint + // By either no using async in data provider/using a different data provider. + // Or by changing the scores to not expect data to be there on initial render. + if ( ! contentTypes.length || ! endpoint ) { + return null; + } + + return ; +}; diff --git a/packages/js/src/general/initialize.js b/packages/js/src/general/initialize.js index 16ffb9e81e2..271fc9ab5a3 100644 --- a/packages/js/src/general/initialize.js +++ b/packages/js/src/general/initialize.js @@ -6,8 +6,9 @@ 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 { DataProvider } from "../dashboard/services/data-provider"; +import { WidgetFactory } from "../dashboard/services/widget-factory"; import { LINK_PARAMS_NAME } from "../shared-admin/store"; -import { ADMIN_NOTICES_NAME } from "./store/admin-notices"; import App from "./app"; import { RouteErrorFallback } from "./components"; import { ConnectedPremiumUpsellList } from "./components/connected-premium-upsell-list"; @@ -15,6 +16,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"; /** @@ -62,11 +64,14 @@ domReady( () => { "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" ), }; + const dataProvider = new DataProvider( { contentTypes, userName, features, endpoints, headers, links } ); + const widgetFactory = new WidgetFactory( dataProvider ); + const router = createHashRouter( createRoutesFromElements( } errorElement={ }> @@ -75,11 +80,10 @@ domReady( () => { element={ From 88e37089c92f2205285841143d68369bd03ba407 Mon Sep 17 00:00:00 2001 From: Igor <35524806+igorschoester@users.noreply.github.com> Date: Thu, 30 Jan 2025 13:07:41 +0100 Subject: [PATCH 3/4] Add top pages widget --- .../components/most-popular-table.js | 46 --------- packages/js/src/dashboard/index.js | 1 + .../src/dashboard/services/widget-factory.js | 4 + .../{components => widgets}/table-widget.js | 24 ++--- .../src/dashboard/widgets/top-pages-widget.js | 93 +++++++++++++++++++ packages/js/src/dashboard/widgets/widget.js | 19 ++++ packages/js/src/general/initialize.js | 3 +- 7 files changed, 131 insertions(+), 59 deletions(-) delete mode 100644 packages/js/src/dashboard/components/most-popular-table.js rename packages/js/src/dashboard/{components => widgets}/table-widget.js (71%) create mode 100644 packages/js/src/dashboard/widgets/top-pages-widget.js create mode 100644 packages/js/src/dashboard/widgets/widget.js 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 cafa31f5e4c..c660014439f 100644 --- a/packages/js/src/dashboard/index.js +++ b/packages/js/src/dashboard/index.js @@ -48,6 +48,7 @@ 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. */ /** diff --git a/packages/js/src/dashboard/services/widget-factory.js b/packages/js/src/dashboard/services/widget-factory.js index 24b505edf33..77c2e712b62 100644 --- a/packages/js/src/dashboard/services/widget-factory.js +++ b/packages/js/src/dashboard/services/widget-factory.js @@ -1,5 +1,6 @@ /* eslint-disable complexity */ import { ScoreWidget } from "../widgets/score-widget"; +import { TopPagesWidget } from "../widgets/top-pages-widget"; /** * @type {import("../index").WidgetType} WidgetType @@ -26,6 +27,7 @@ export class WidgetFactory { return [ { type: "seoScores" }, { type: "readabilityScores" }, + { type: "topPages" }, ]; } @@ -48,6 +50,8 @@ export class WidgetFactory { return null; } return ; + case "topPages": + return ; } } } 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..9c381b20db0 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 }
-
+ ); }; 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..d82f3ac488a --- /dev/null +++ b/packages/js/src/dashboard/widgets/top-pages-widget.js @@ -0,0 +1,93 @@ +import { useEffect, useState } from "@wordpress/element"; +import { __ } from "@wordpress/i18n"; +import { Alert, SkeletonLoader } from "@yoast/ui-library"; +import { TableWidget } from "./table-widget"; +import { Widget } from "./widget"; + +/** + * @type {import("../index").MostPopularContent} Most popular content + */ + +const TITLE = __( "Top 5 most popular content", "wordpress-seo" ); + +/** + * @param {[MostPopularContent]} data The data. + * @param {JSX.Element} [children] The children. Use this to override the data rendering. + * @returns {JSX.Element} The element. + */ +export const MostPopularTable = ( { 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 {number} [limit=5] The limit. + * @returns {JSX.Element} The element. + */ +export const TopPagesWidget = ( { dataProvider, limit = 5 } ) => { + const [ data, setData ] = useState( [] ); + const [ error, setError ] = useState( null ); + const [ isPending, setIsPending ] = useState( true ); + + useEffect( () => { + const controller = new AbortController(); + dataProvider.getTopPages( limit, controller.signal ) + .then( ( response ) => setData( response ) ) + .catch( ( e ) => setError( e ) ) + .finally( () => setIsPending( false ) ); + + return () => controller.abort(); + }, [ dataProvider, limit ] ); + + if ( isPending ) { + return + { Array.from( { length: limit }, ( _, index ) => ( + + https://example.com/page + 10 + 100 + 0.12 + 12.34 + +
+ +
+
+
+ ) ) } +
; + } + + if ( error ) { + return ( + + + { error.message } + + + ); + } + + 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..b233a553fef --- /dev/null +++ b/packages/js/src/dashboard/widgets/widget.js @@ -0,0 +1,19 @@ +import { Paper, Title } from "@yoast/ui-library"; + +/** + * @param {string} [title] The title in an H2. + * @param {JSX.Element} children The content. + * @returns {JSX.Element} The element. + */ +export const Widget = ( { title, children } ) => { + return ( + + { title && ( + + { title } + + ) } + { children } + + ); +}; diff --git a/packages/js/src/general/initialize.js b/packages/js/src/general/initialize.js index 271fc9ab5a3..b86b8644071 100644 --- a/packages/js/src/general/initialize.js +++ b/packages/js/src/general/initialize.js @@ -58,6 +58,7 @@ 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 = { @@ -81,7 +82,7 @@ domReady( () => { Date: Thu, 30 Jan 2025 16:54:32 +0100 Subject: [PATCH 4/4] Add remote data provider --- packages/js/src/dashboard/index.js | 4 +- .../src/dashboard/services/data-provider.js | 83 +++++++----------- .../services/remote-data-provider.js | 47 ++++++++++ .../src/dashboard/services/use-async-data.js | 51 +++++++++++ .../src/dashboard/services/widget-factory.js | 10 ++- .../js/src/dashboard/widgets/score-widget.js | 23 ++--- .../js/src/dashboard/widgets/table-widget.js | 1 - .../src/dashboard/widgets/top-pages-widget.js | 85 +++++++++++-------- packages/js/src/dashboard/widgets/widget.js | 15 ++-- packages/js/src/general/initialize.js | 4 +- 10 files changed, 208 insertions(+), 115 deletions(-) create mode 100644 packages/js/src/dashboard/services/remote-data-provider.js create mode 100644 packages/js/src/dashboard/services/use-async-data.js diff --git a/packages/js/src/dashboard/index.js b/packages/js/src/dashboard/index.js index c660014439f..5170024dcb7 100644 --- a/packages/js/src/dashboard/index.js +++ b/packages/js/src/dashboard/index.js @@ -57,7 +57,7 @@ export { Dashboard } from "./components/dashboard"; */ /** - * @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. @@ -67,7 +67,7 @@ export { Dashboard } from "./components/dashboard"; */ /** - * @typedef {"seoScores"|"readabilityScores"|"popularContent"} WidgetType The widget type. + * @typedef {"seoScores"|"readabilityScores"|"topPages"} WidgetType The widget type. */ /** diff --git a/packages/js/src/dashboard/services/data-provider.js b/packages/js/src/dashboard/services/data-provider.js index 7a135134aab..780b6a645ab 100644 --- a/packages/js/src/dashboard/services/data-provider.js +++ b/packages/js/src/dashboard/services/data-provider.js @@ -3,8 +3,12 @@ * @type {import("../index").Features} Features * @type {import("../index").Endpoints} Endpoints * @type {import("../index").Links} Links + * @type {import("../index").TopPageData} TopPageData */ +/** + * Controls the data. + */ export class DataProvider { #contentTypes; #userName; @@ -20,7 +24,6 @@ export class DataProvider { * @param {Endpoints} endpoints The endpoints. * @param {Object} headers The headers for the WP requests. * @param {Links} links The links. - * @constructor */ constructor( { contentTypes, userName, features, endpoints, headers, links } ) { this.#contentTypes = contentTypes; @@ -31,72 +34,48 @@ export class DataProvider { this.#links = links; } - async getContentTypes() { + /** + * @returns {Promise} The content types. + */ + getContentTypes() { return this.#contentTypes; } - async getUserName() { + /** + * @returns {Promise} The user name. + */ + getUserName() { return this.#userName; } - async hasFeature( feature ) { + /** + * @param {string} feature The feature to check. + * @returns {Promise} Whether the feature is enabled. + */ + hasFeature( feature ) { return this.#features?.[ feature ] === true; } - async getEndpoint( endpoint ) { - return this.#endpoints?.[ endpoint ]; + /** + * @param {string} id The identifier. + * @returns {Promise} The endpoint, if found. + */ + getEndpoint( id ) { + return this.#endpoints?.[ id ]; } - async getHeaders() { + /** + * @returns {Promise>} The headers for making requests to the endpoints. + */ + getHeaders() { return this.#headers; } - async getLink( id ) { - return this.#links?.[ id ]; - } - /** - * @param {string} endpoint The endpoint. - * @param {number?} limit The number of results to return. - * @throws {TypeError} If the URL is invalid. - * @link https://developer.mozilla.org/en-US/docs/Web/API/URL - * @returns {URL} The URL to get the most popular content. + * @param {string} id The identifier. + * @returns {Promise} The link, if found. */ - static #createTopPagesUrl( endpoint, limit ) { - const url = new URL( endpoint ); - - if ( limit ) { - url.searchParams.set( "limit", limit.toString( 10 ) ); - } - - return url; - } - - async getTopPages( limit, signal ) { - try { - const response = await fetch( - DataProvider.#createTopPagesUrl( this.#endpoints.topPages, limit ), - { - method: "GET", - headers: { - "Content-Type": "application/json", - ...this.#headers, - }, - signal, - } - ); - if ( ! response.ok ) { - throw new Error( "Not OK" ); - } - - const data = await response.json(); - if ( ! data || ! Array.isArray( data ) ) { - throw new Error( "Invalid data" ); - } - - return data; - } catch ( error ) { - throw new Error( `Failed to fetch most popular content: ${ error }` ); - } + getLink( id ) { + return this.#links?.[ id ]; } } 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-async-data.js b/packages/js/src/dashboard/services/use-async-data.js new file mode 100644 index 00000000000..f50cad9c591 --- /dev/null +++ b/packages/js/src/dashboard/services/use-async-data.js @@ -0,0 +1,51 @@ +import { createSlice } from "@reduxjs/toolkit"; +import { useEffect, useReducer, useRef } from "@wordpress/element"; + +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 ); + }, + }, +} ); + +export const useAsyncData = ( getter ) => { + 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 ) ); + getter( { signal: controller.current?.signal } ) + .then( ( response ) => dispatch( slice.actions.setData( 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(); + }, [ getter ] ); + + return state; +}; diff --git a/packages/js/src/dashboard/services/widget-factory.js b/packages/js/src/dashboard/services/widget-factory.js index 77c2e712b62..6579549e847 100644 --- a/packages/js/src/dashboard/services/widget-factory.js +++ b/packages/js/src/dashboard/services/widget-factory.js @@ -12,12 +12,15 @@ import { TopPagesWidget } from "../widgets/top-pages-widget"; */ export class WidgetFactory { #dataProvider; + #remoteDataProvider; /** * @param {import("./data-provider").DataProvider} dataProvider + * @param {import("./remote-data-provider").RemoteDataProvider} remoteDataProvider */ - constructor( dataProvider ) { + constructor( dataProvider, remoteDataProvider ) { this.#dataProvider = dataProvider; + this.#remoteDataProvider = remoteDataProvider; } /** @@ -40,7 +43,6 @@ export class WidgetFactory { createWidget( widget, onRemove ) { switch ( widget.type ) { case "seoScores": - // TODO: Should this move into a FeatureWidget, or just into the ScoreWidget itself? if ( ! this.#dataProvider.hasFeature( "indexables" ) && this.#dataProvider.hasFeature( "seoAnalysis" ) ) { return null; } @@ -51,7 +53,9 @@ export class WidgetFactory { } return ; case "topPages": - return ; + return ; + default: + return null; } } } diff --git a/packages/js/src/dashboard/widgets/score-widget.js b/packages/js/src/dashboard/widgets/score-widget.js index 3fe73799e8a..0aaad073db9 100644 --- a/packages/js/src/dashboard/widgets/score-widget.js +++ b/packages/js/src/dashboard/widgets/score-widget.js @@ -2,27 +2,16 @@ import { useEffect, useState } from "@wordpress/element"; import { Scores } from "../scores/components/scores"; export const ScoreWidget = ( { analysisType, dataProvider } ) => { - const [ contentTypes, setContentTypes ] = useState( [] ); - const [ endpoint, setEndpoint ] = useState( "" ); - const [ headers, setHeaders ] = useState( {} ); + const [ contentTypes, setContentTypes ] = useState( () => dataProvider.getContentTypes() ); + const [ endpoint, setEndpoint ] = useState( () => dataProvider.getEndpoint( analysisType + "Scores" ) ); + const [ headers, setHeaders ] = useState( () => dataProvider.getHeaders() ); useEffect( () => { - // Note: fetching all and setting headers before endpoint or the requests will fail. - Promise.allSettled( [ - dataProvider.getContentTypes(), - dataProvider.getEndpoint( analysisType + "Scores" ), - dataProvider.getHeaders(), - ] ).then( ( [ contentTypes, endpoint, headers ] ) => { - setHeaders( headers.value ); - setEndpoint( endpoint.value ); - setContentTypes( contentTypes.value ); - } ); + setHeaders( dataProvider.getHeaders() ); + setEndpoint( dataProvider.getEndpoint( analysisType + "Scores" ) ); + setContentTypes( dataProvider.getContentTypes() ); }, [ dataProvider ] ); - // TODO: initial implementation expects data to be there before rendering. - // Rework to handle async contentTypes and endpoint - // By either no using async in data provider/using a different data provider. - // Or by changing the scores to not expect data to be there on initial render. if ( ! contentTypes.length || ! endpoint ) { return null; } diff --git a/packages/js/src/dashboard/widgets/table-widget.js b/packages/js/src/dashboard/widgets/table-widget.js index 9c381b20db0..29e749e32a6 100644 --- a/packages/js/src/dashboard/widgets/table-widget.js +++ b/packages/js/src/dashboard/widgets/table-widget.js @@ -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 index d82f3ac488a..679ec20b997 100644 --- a/packages/js/src/dashboard/widgets/top-pages-widget.js +++ b/packages/js/src/dashboard/widgets/top-pages-widget.js @@ -1,21 +1,42 @@ -import { useEffect, useState } from "@wordpress/element"; +import { useCallback } from "@wordpress/element"; import { __ } from "@wordpress/i18n"; import { Alert, SkeletonLoader } from "@yoast/ui-library"; +import { useAsyncData } from "../services/use-async-data"; import { TableWidget } from "./table-widget"; import { Widget } from "./widget"; /** - * @type {import("../index").MostPopularContent} Most popular content + * @type {import("../index").TopPageData} TopPageData */ +/** @type {string} */ const TITLE = __( "Top 5 most popular content", "wordpress-seo" ); /** - * @param {[MostPopularContent]} data The data. + * @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. */ -export const MostPopularTable = ( { data, children } ) => { +const TopPagesTable = ( { data, children } ) => { return { __( "Landing page", "wordpress-seo" ) } @@ -42,41 +63,25 @@ export const MostPopularTable = ( { data, children } ) => { /** * @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, limit = 5 } ) => { - const [ data, setData ] = useState( [] ); - const [ error, setError ] = useState( null ); - const [ isPending, setIsPending ] = useState( true ); - - useEffect( () => { - const controller = new AbortController(); - dataProvider.getTopPages( limit, controller.signal ) - .then( ( response ) => setData( response ) ) - .catch( ( e ) => setError( e ) ) - .finally( () => setIsPending( false ) ); - - return () => controller.abort(); +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 } = useAsyncData( getTopPages ); + if ( isPending ) { - return - { Array.from( { length: limit }, ( _, index ) => ( - - https://example.com/page - 10 - 100 - 0.12 - 12.34 - -
- -
-
-
- ) ) } -
; + return ( + + { Array.from( { length: limit }, ( _, index ) => ( + + ) ) } + + ); } if ( error ) { @@ -89,5 +94,17 @@ export const TopPagesWidget = ( { dataProvider, limit = 5 } ) => { ); } - return ; + 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 index b233a553fef..8e6116fcf86 100644 --- a/packages/js/src/dashboard/widgets/widget.js +++ b/packages/js/src/dashboard/widgets/widget.js @@ -1,5 +1,12 @@ import { Paper, Title } from "@yoast/ui-library"; +const WidgetTitle = ( { children, ...props } ) => ( + + { children } + +); +WidgetTitle.displayName = "Widget.Title"; + /** * @param {string} [title] The title in an H2. * @param {JSX.Element} children The content. @@ -8,12 +15,10 @@ import { Paper, Title } from "@yoast/ui-library"; export const Widget = ( { title, children } ) => { return ( - { title && ( - - { title } - - ) } + { title && { title } } { children } ); }; + +Widget.Title = WidgetTitle; diff --git a/packages/js/src/general/initialize.js b/packages/js/src/general/initialize.js index b86b8644071..4b80734ab6f 100644 --- a/packages/js/src/general/initialize.js +++ b/packages/js/src/general/initialize.js @@ -7,6 +7,7 @@ import { get } from "lodash"; import { createHashRouter, createRoutesFromElements, Navigate, Route, RouterProvider } from "react-router-dom"; import { Dashboard } from "../dashboard"; import { DataProvider } from "../dashboard/services/data-provider"; +import { RemoteDataProvider } from "../dashboard/services/remote-data-provider"; import { WidgetFactory } from "../dashboard/services/widget-factory"; import { LINK_PARAMS_NAME } from "../shared-admin/store"; import App from "./app"; @@ -70,8 +71,9 @@ domReady( () => { dashboardLearnMore: select( STORE_NAME ).selectLink( "https://yoa.st/dashboard-learn-more" ), }; + const remoteDataProvider = new RemoteDataProvider( { headers } ); const dataProvider = new DataProvider( { contentTypes, userName, features, endpoints, headers, links } ); - const widgetFactory = new WidgetFactory( dataProvider ); + const widgetFactory = new WidgetFactory( dataProvider, remoteDataProvider ); const router = createHashRouter( createRoutesFromElements(