Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create frontend providers and widget factory #22008

Draft
wants to merge 4 commits into
base: trunk
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 27 additions & 10 deletions packages/js/src/dashboard/components/dashboard.js
Original file line number Diff line number Diff line change
@@ -1,33 +1,50 @@
import { Scores } from "../scores/components/scores";
import { useCallback, useState } from "@wordpress/element";
import { PageTitle } from "./page-title";

/**
* @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<string,string>} 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 (
<>
<PageTitle userName={ userName } features={ features } links={ links } />
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be a widget too?

<div className="yst-flex yst-flex-col @7xl:yst-flex-row yst-gap-6 yst-my-6">
{ features.indexables && features.seoAnalysis && (
<Scores analysisType="seo" contentTypes={ contentTypes } endpoint={ endpoints.seoScores } headers={ headers } />
) }
{ features.indexables && features.readabilityAnalysis && (
<Scores analysisType="readability" contentTypes={ contentTypes } endpoint={ endpoints.readabilityScores } headers={ headers } />
) }
{ widgets.map( ( widget ) => widgetFactory.createWidget( widget, removeWidget ) ) }
</div>
</>
);
Expand Down
46 changes: 0 additions & 46 deletions packages/js/src/dashboard/components/most-popular-table.js
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed and moved into the top-pages-widget

This file was deleted.

20 changes: 18 additions & 2 deletions packages/js/src/dashboard/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/

/**
Expand All @@ -56,11 +57,26 @@ 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.
* @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.
*/
81 changes: 81 additions & 0 deletions packages/js/src/dashboard/services/data-provider.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* @type {import("../index").ContentType} ContentType
* @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;
#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<string,string>} headers The headers for the WP requests.
* @param {Links} links The links.
*/
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;
}

/**
* @returns {Promise<ContentType[]>} The content types.
*/
getContentTypes() {
return this.#contentTypes;
}

/**
* @returns {Promise<string>} The user name.
*/
getUserName() {
return this.#userName;
}

/**
* @param {string} feature The feature to check.
* @returns {Promise<boolean>} Whether the feature is enabled.
*/
hasFeature( feature ) {
return this.#features?.[ feature ] === true;
}

/**
* @param {string} id The identifier.
* @returns {Promise<?string>} The endpoint, if found.
*/
getEndpoint( id ) {
return this.#endpoints?.[ id ];
}

/**
* @returns {Promise<Object<string,string>>} The headers for making requests to the endpoints.
*/
getHeaders() {
return this.#headers;
}

/**
* @param {string} id The identifier.
* @returns {Promise<?string>} The link, if found.
*/
getLink( id ) {
return this.#links?.[ id ];
}
}
47 changes: 47 additions & 0 deletions packages/js/src/dashboard/services/remote-data-provider.js
Original file line number Diff line number Diff line change
@@ -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<string,string>} [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<string,string>} [params] The query parameters.
* @param {RequestInit} [options] The request options.
* @returns {Promise<any|Error>} 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" } } )
);
}
}
51 changes: 51 additions & 0 deletions packages/js/src/dashboard/services/use-async-data.js
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like a friendly hook for more data/error/isPending widgets. However, I'm not sure how caching/debouncing should be incorperated yet.

Original file line number Diff line number Diff line change
@@ -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<AbortController>} */
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;
};
61 changes: 61 additions & 0 deletions packages/js/src/dashboard/services/widget-factory.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/* eslint-disable complexity */
import { ScoreWidget } from "../widgets/score-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" },
];
}
Comment on lines +26 to +35
Copy link
Member Author

@igorschoester igorschoester Jan 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe remove? As it is not needed yet, if ever


/**
* @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 ) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onRemove is not yet used. Should be under the widget menu eventually, right?

Probably nice to have some RemoveWidget base that handles this in the future?

switch ( widget.type ) {
case "seoScores":
if ( ! this.#dataProvider.hasFeature( "indexables" ) && this.#dataProvider.hasFeature( "seoAnalysis" ) ) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this move into the Widget? And is it worth an Widget layer if more need this?

return null;
}
return <ScoreWidget key={ widget.id } analysisType="seo" dataProvider={ this.#dataProvider } />;
case "readabilityScores":
if ( ! this.#dataProvider.hasFeature( "indexables" ) && this.#dataProvider.hasFeature( "readabilityAnalysis" ) ) {
return null;
}
return <ScoreWidget key={ widget.id } analysisType="readability" dataProvider={ this.#dataProvider } />;
case "topPages":
return <TopPagesWidget key={ widget.id } dataProvider={ this.#dataProvider } remoteDataProvider={ this.#remoteDataProvider } />;
default:
return null;
}
}
}
Loading