Skip to content

Commit

Permalink
Merge pull request #22008 from Yoast/420-create-frontend-data-provide…
Browse files Browse the repository at this point in the history
…r-and-wp-implementation

Create frontend providers and widget factory
  • Loading branch information
vraja-pro authored Feb 10, 2025
2 parents 0a28ab5 + a07f11c commit 2d39f81
Show file tree
Hide file tree
Showing 26 changed files with 1,077 additions and 320 deletions.
1 change: 1 addition & 0 deletions packages/js/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ module.exports = {
setupFilesAfterEnv: [ "<rootDir>/tests/setupTests.js" ],
testPathIgnorePatterns: [
"/tests/__mocks__/",
"/tests/dashboard/__mocks__/",
"/tests/containers/mockSelectors.js",
"/tests/helpers/factory.js",
"/tests/setupTests.js",
Expand Down
66 changes: 26 additions & 40 deletions packages/js/src/dashboard/components/dashboard.js
Original file line number Diff line number Diff line change
@@ -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<string,string>} 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 (
<>
<PageTitle userName={ userName } features={ features } links={ links } />
{ showGoogleSiteKit && siteKitConfiguration.isFeatureEnabled && <SiteKitSetupWidget
{ ...siteKitConfiguration }
learnMoreLink={ learnMorelink }
onRemove={ setRemoveGoogleSiteKit }
onRemovePermanently={ handleRemovePermanently }
/> }
<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

This file was deleted.

33 changes: 31 additions & 2 deletions packages/js/src/dashboard/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
136 changes: 65 additions & 71 deletions packages/js/src/dashboard/scores/components/scores.js
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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>", "</link>" ), { link } );
} catch ( e ) {
return sprintf( message, "", "" );
}

return url;
};

// Added dummy space as content to prevent children prop warnings in the console.
const supportLink = <Link variant="error" href="admin.php?page=wpseo_page_support"> </Link>;

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>",
"</supportLink>"
),
{
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>",
"</supportLink>"
),
{
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 = <Link variant="error" href={ supportLink }> </Link>;

return (
<Alert variant="error">
{ 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
)
}
</Alert>
);
};

/**
* @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<string,string>} 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 (
<Paper className="yst-@container yst-grow yst-max-w-screen-sm yst-p-8 yst-shadow-md">
<Title as="h2">
{ analysisType === "readability"
? __( "Readability scores", "wordpress-seo" )
: __( "SEO scores", "wordpress-seo" )
}
</Title>
<>
<div className="yst-grid yst-grid-cols-1 @md:yst-grid-cols-2 yst-gap-6 yst-mt-4">
<ContentTypeFilter
idSuffix={ analysisType }
Expand All @@ -131,7 +125,7 @@ export const Scores = ( { analysisType, contentTypes, endpoint, headers } ) => {
}
</div>
<div className="yst-mt-6">
<ErrorAlert error={ error } />
<ErrorAlert error={ error } supportLink={ dataProvider.getLink( "errorSupport" ) } />
{ ! error && (
<ScoreContent
scores={ scores }
Expand All @@ -141,6 +135,6 @@ export const Scores = ( { analysisType, contentTypes, endpoint, headers } ) => {
/>
) }
</div>
</Paper>
</>
);
};
Loading

0 comments on commit 2d39f81

Please sign in to comment.