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

Add Organic Sessions widget #22073

Merged
merged 38 commits into from
Mar 5, 2025
Merged
Show file tree
Hide file tree
Changes from 34 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
2d4e5da
Add Organic Sessions widget
igorschoester Feb 25, 2025
e24b710
Merge remote-tracking branch 'origin/trunk' into 411-implement-organi…
vraja-pro Feb 26, 2025
a293a3f
use dashboard error alert
vraja-pro Feb 26, 2025
7e2b332
fix chart height
vraja-pro Feb 26, 2025
a7c62d5
fix fonts size and weight
vraja-pro Feb 26, 2025
ea51682
adjust chart label styling and size
vraja-pro Feb 26, 2025
cb87370
align y ticks to the title
vraja-pro Feb 26, 2025
4cf9ecd
fix widget title
vraja-pro Feb 27, 2025
b3cd14b
tests: OrganicSessionsChange
vraja-pro Feb 27, 2025
43bac68
add tests to daily organic sessions
vraja-pro Feb 27, 2025
33adf85
fix info tooltip location
vraja-pro Feb 27, 2025
50b6a4c
dd condition to organic sessions in the widget factory
vraja-pro Feb 27, 2025
ceae3bb
mock canvas for daily chart
vraja-pro Feb 27, 2025
33a151b
move the no data message one layer up
vraja-pro Feb 27, 2025
d2ca2ef
when pending there is no data
vraja-pro Feb 27, 2025
25c5492
change the gradient to match the graph size
vraja-pro Feb 27, 2025
b5f564d
refactor tests
vraja-pro Feb 27, 2025
3f4d58e
wip tests
vraja-pro Feb 27, 2025
fc51eab
remove uncompleted test
vraja-pro Feb 28, 2025
5643a14
Fix formula
igorschoester Mar 3, 2025
ebe7e14
Abstract no data paragraph
igorschoester Mar 3, 2025
fcdcf4c
Abstract difference percentage
igorschoester Mar 3, 2025
0bba2b6
Refactor left alignment of the chart
igorschoester Mar 3, 2025
c845e53
Internal refactor: use single Layout
igorschoester Mar 3, 2025
301d9e9
Rename: Change to Compare
igorschoester Mar 3, 2025
7723442
Cleanup test warnings
igorschoester Mar 3, 2025
5f350c0
Check if Analytics is connected
igorschoester Mar 3, 2025
f5ca986
Refactor difference
igorschoester Mar 4, 2025
2eab5c8
Remove daily session from formatter
igorschoester Mar 4, 2025
7b8c4df
Add data formatter test cases
igorschoester Mar 4, 2025
c7b3929
Fix accidental Greek letter
igorschoester Mar 4, 2025
0577289
Format Y-axis
igorschoester Mar 4, 2025
5fb7e94
Merge branch 'trunk' of github.com:Yoast/wordpress-seo into 411-imple…
igorschoester Mar 4, 2025
d3b6b1c
Merge branch 'trunk' of github.com:Yoast/wordpress-seo into 411-imple…
igorschoester Mar 4, 2025
499060c
Fix name
igorschoester Mar 5, 2025
f63190e
Fix copy
igorschoester Mar 5, 2025
1dd0405
Remove format from Y-axis
igorschoester Mar 5, 2025
bebd593
fix lint
vraja-pro Mar 5, 2025
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
2 changes: 1 addition & 1 deletion packages/js/src/dashboard/components/info-tooltip.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { InformationCircleIcon } from "@heroicons/react/outline";
* @returns {JSX.Element} The element.
*/
export const InfoTooltip = ( { children } ) => (
<TooltipContainer>
<TooltipContainer className="yst-h-fit yst-leading-[0]">
<TooltipTrigger>
<InformationCircleIcon className="yst-w-5 yst-h-5 yst-text-slate-400" />
</TooltipTrigger>
Expand Down
11 changes: 11 additions & 0 deletions packages/js/src/dashboard/components/no-data-paragraph.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { __ } from "@wordpress/i18n";

/**
* @param {string} [className="yst-mt-4"] The class name.
* @returns {JSX.Element} The element.
*/
export const NoDataParagraph = ( { className = "yst-mt-4" } ) => (
<p className={ className }>
{ __( "No data to display: Your site hasn't received any visitors yet.", "wordpress-seo" ) }
</p>
);
35 changes: 35 additions & 0 deletions packages/js/src/dashboard/components/trend.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { ArrowNarrowUpIcon } from "@heroicons/react/outline";
import classNames from "classnames";

/**
* @param {number} value The value.
* @param {string} formattedValue The formatted value.
* @returns {JSX.Element} The element.
*/
export const Trend = ( { value, formattedValue } ) => {
// Don't show anything if 0 or invalid.
if ( ! value ) {
return null;
}

const isPositive = value >= 0;

return (
<div
className={ classNames(
"yst-flex yst-items-center yst-font-semibold",
isPositive ? "yst-text-green-600" : "yst-text-red-600"
) }
>
<ArrowNarrowUpIcon
className={ classNames(
"yst-w-4 yst-shrink-0",
// Point the arrow downwards if negative.
! isPositive && "yst-rotate-180"
) }
/>
{ isPositive && "+" }
{ formattedValue }
</div>
);
};
4 changes: 3 additions & 1 deletion packages/js/src/dashboard/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export { Dashboard } from "./components/dashboard";
* @property {string} installSiteKit The Site Kit installation link.
* @property {string} activateSiteKit The Site Kit activation link.
* @property {string} setupSiteKit The Site Kit setup link.
* @property {string} organicSessionsInfoLearnMore The organic sessions learn more link.
*/

/**
Expand Down Expand Up @@ -92,7 +93,7 @@ export { Dashboard } from "./components/dashboard";
*/

/**
* @typedef {"seoScores"|"readabilityScores"|"topPages"|"siteKitSetup"|"topQueries"} WidgetType The widget type.
* @typedef {"seoScores"|"readabilityScores"|"topPages"|"siteKitSetup"|"topQueries"|"organicSessions"} WidgetType The widget type.
*/

/**
Expand All @@ -107,6 +108,7 @@ export { Dashboard } from "./components/dashboard";
* @property {boolean} isActive Whether Site Kit is active.
* @property {boolean} isSetupCompleted Whether Site Kit is setup.
* @property {boolean} isConsentGranted Whether Site Kit is connected.
* @property {boolean} isAnalyticsConnected Whether Google Analytics is connected.
* @property {boolean} isFeatureEnabled Whether the feature is enabled.
* @property {boolean} isSetupWidgetDismissed Whether the configuration is dismissed.
*/
20 changes: 20 additions & 0 deletions packages/js/src/dashboard/services/data-formatter.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,20 @@ const safeNumberFormat = ( data, numberFormat ) => {
* Knows how to format data.
*/
export class DataFormatter {
#locale;
#numberFormat = {};

/**
* @param {string} [locale] The locale.
*/
constructor( { locale = "en-US" } = {} ) {
this.#locale = locale;
this.#numberFormat.nonFractional = new Intl.NumberFormat( locale, { maximumFractionDigits: 0 } );
this.#numberFormat.compactNonFractional = new Intl.NumberFormat( locale, {
maximumFractionDigits: 0,
notation: "compact",
compactDisplay: "short",
} );
this.#numberFormat.percentage = new Intl.NumberFormat( locale, { style: "percent", minimumFractionDigits: 2, maximumFractionDigits: 2 } );
this.#numberFormat.twoFractions = new Intl.NumberFormat( locale, { maximumFractionDigits: 2, minimumFractionDigits: 2 } );
}
Expand Down Expand Up @@ -75,11 +82,24 @@ export class DataFormatter {
case "impressions":
return safeNumberFormat( data, this.#numberFormat.nonFractional );
case "ctr":
case "difference":
return safeNumberFormat( data, this.#numberFormat.percentage );
case "position":
return safeNumberFormat( data, this.#numberFormat.twoFractions );
case "seoScore":
return Object.keys( SCORE_META ).includes( data ) ? data : "notAnalyzed";
case "date":
return new Date(
Date.UTC( data.slice( 0, 4 ), data.slice( 4, 6 ) - 1, data.slice( 6, 8 ) )
).toLocaleDateString(
this.#locale,
{ month: "short", day: "numeric" }
);
case "sessions":
if ( context.compact ) {
return safeNumberFormat( data || 0, this.#numberFormat.compactNonFractional );
}
return safeNumberFormat( data || 0, this.#numberFormat.nonFractional );
default:
return data;
}
Expand Down
14 changes: 8 additions & 6 deletions packages/js/src/dashboard/services/data-provider.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export class DataProvider {
this.#siteKitConfiguration = {
isFeatureEnabled: siteKitConfiguration.isFeatureEnabled,
isSetupWidgetDismissed: siteKitConfiguration.isSetupWidgetDismissed,
isAnalyticsConnected: siteKitConfiguration.isAnalyticsConnected,
};
this.#stepsStatuses = [
siteKitConfiguration.isInstalled,
Expand All @@ -51,18 +52,18 @@ export class DataProvider {
}

/**
* Subscribe to changes in the site kit configuration.
* @param {Function} callback The callback to call when the configuration changes.
* @returns {Function} Unsubscribe function.
*/
* Subscribe to changes in the site kit configuration.
* @param {Function} callback The callback to call when the configuration changes.
* @returns {Function} Unsubscribe function.
*/
subscribe( callback ) {
this.#subscribers.add( callback );
return () => this.#subscribers.delete( callback );
}

/**
* Notify all subscribers of a change in the site kit configuration.
*/
* Notify all subscribers of a change in the site kit configuration.
*/
notifySubscribers() {
this.#subscribers.forEach( callback => callback() );
}
Expand All @@ -87,6 +88,7 @@ export class DataProvider {
getStepsStatuses() {
return this.#stepsStatuses;
}

/**
* @param {string} feature The feature to check.
* @returns {boolean} Whether the feature is enabled.
Expand Down
4 changes: 2 additions & 2 deletions packages/js/src/dashboard/services/remote-data-provider.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export class RemoteDataProvider {

/**
* @param {string|URL} endpoint The endpoint.
* @param {Object<string,string>} [params] The query parameters.
* @param {Object<string,string|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.
Expand All @@ -40,7 +40,7 @@ export class RemoteDataProvider {

/**
* @param {string|URL} endpoint The endpoint.
* @param {Object<string,string>} [params] The query parameters.
* @param {Object<string,string|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.
*/
Expand Down
16 changes: 14 additions & 2 deletions packages/js/src/dashboard/services/widget-factory.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/* eslint-disable complexity */
import { OrganicSessionsWidget } from "../widgets/organic-sessions-widget";
import { ScoreWidget } from "../widgets/score-widget";
import { SiteKitSetupWidget } from "../widgets/site-kit-setup-widget";
import { TopPagesWidget } from "../widgets/top-pages-widget";
Expand Down Expand Up @@ -28,13 +29,14 @@ export class WidgetFactory {
}

/**
* The widget types, also determaines the order in which they are displayed.!
* The widget types, also determines the order in which they are displayed.!
*
* @returns {Object} The widget types.
*/
static get types() {
return {
siteKitSetup: "siteKitSetup",
organicSessions: "organicSessions",
topPages: "topPages",
topQueries: "topQueries",
seoScores: "seoScores",
Expand All @@ -47,7 +49,7 @@ export class WidgetFactory {
* @returns {JSX.Element|null} The widget or null.
*/
createWidget( widget ) {
const { isFeatureEnabled, isSetupWidgetDismissed } = this.#dataProvider.getSiteKitConfiguration();
const { isFeatureEnabled, isSetupWidgetDismissed, isAnalyticsConnected } = this.#dataProvider.getSiteKitConfiguration();
const isSiteKitConnectionCompleted = this.#dataProvider.isSiteKitConnectionCompleted();
switch ( widget.type ) {
case WidgetFactory.types.seoScores:
Expand Down Expand Up @@ -99,6 +101,16 @@ export class WidgetFactory {
remoteDataProvider={ this.#remoteDataProvider }
dataFormatter={ this.#dataFormatter }
/>;
case WidgetFactory.types.organicSessions:
if ( ! isFeatureEnabled || ! isSiteKitConnectionCompleted || ! isAnalyticsConnected ) {
return null;
}
return <OrganicSessionsWidget
key={ widget.id }
dataProvider={ this.#dataProvider }
remoteDataProvider={ this.#remoteDataProvider }
dataFormatter={ this.#dataFormatter }
/>;
default:
return null;
}
Expand Down
30 changes: 30 additions & 0 deletions packages/js/src/dashboard/transformers/difference.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* @param {number} number The number to check.
* @returns {boolean} True if not falsy except 0.
*/
const isValidNumber = ( number ) => ! number && number !== 0;

/**
* @param {number} current The current.
* @param {number} previous The previous.
* @returns {number} The difference, as percentage.
*/
export const getDifference = ( current, previous ) => {
// Invalid values (falsy except 0) => return NaN.
// Because there is nothing to say about the difference.
if ( isValidNumber( current ) || isValidNumber( previous ) ) {
return NaN;
}

// No difference, preventing the both 0 divide by 0.
if ( current === previous ) {
return 0;
}

// Divide by 0. Instead of Infinite, we go for "logical" 100% increase instead.
if ( previous === 0 ) {
return 1;
}

return ( current - previous ) / previous;
};
71 changes: 71 additions & 0 deletions packages/js/src/dashboard/widgets/organic-sessions-widget.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { useCallback } from "@wordpress/element";
import { __ } from "@wordpress/i18n";
import { isEqual } from "lodash";
import { ErrorAlert } from "../components/error-alert";
import { NoDataParagraph } from "../components/no-data-paragraph";
import { OrganicSessionsCompare, useOrganicSessionsCompare } from "./organic-sessions/compare";
import { OrganicSessionsDaily, useOrganicSessionsDaily } from "./organic-sessions/daily";
import { Widget } from "./widget";

/**
* @type {import("../services/data-provider")} DataProvider
* @type {import("../services/remote-data-provider")} RemoteDataProvider
* @type {import("../services/data-formatter")} DataFormatter
*/

/**
* @param {DataProvider} dataProvider The data provider.
* @param {RemoteDataProvider} remoteDataProvider The remote data provider.
* @param {DataFormatter} dataFormatter The data formatter.
* @returns {JSX.Element} The element.
*/
export const OrganicSessionsWidget = ( { dataProvider, remoteDataProvider, dataFormatter } ) => {
const supportLink = dataProvider.getLink( "errorSupport" );
const daily = useOrganicSessionsDaily( dataProvider, remoteDataProvider, dataFormatter );
const compare = useOrganicSessionsCompare( dataProvider, remoteDataProvider, dataFormatter );
const widgetProps = {
className: "yst-paper__content yst-col-span-4",
title: __( "Organic sessions", "wordpress-seo" ),
tooltip: __( "The number of organic sessions on your website.", "wordpress-seo" ),
tooltipLearnMoreLink: dataProvider.getLink( "organicSessionsInfoLearnMore" ),
};

// Creating a specific wrapper instead of passing the data formatter itself.
const formatSessionsForChartLabel = useCallback(
( value ) => dataFormatter.format( value, "sessions", { compact: true } ),
[ dataFormatter ]
);

// Collapse the errors if they are the same.
if ( compare.error && daily.error && isEqual( compare.error, daily.error ) ) {
return (
<Widget { ...widgetProps }>
<ErrorAlert className="yst-mt-4" error={ compare.error } supportLink={ supportLink } />
</Widget>
);
}

// Don't show the comparison when there is not data.
if ( daily.data?.labels.length === 0 ) {
return (
<Widget { ...widgetProps }>
<NoDataParagraph />
</Widget>
);
}

return (
<Widget { ...widgetProps }>
<div className="yst-flex yst-justify-between yst-mt-4">
<OrganicSessionsCompare data={ compare.data } error={ compare.error } isPending={ compare.isPending } supportLink={ supportLink } />
</div>
<OrganicSessionsDaily
data={ daily.data }
error={ daily.error }
isPending={ daily.isPending }
supportLink={ supportLink }
formatY={ formatSessionsForChartLabel }
/>
</Widget>
);
};
Loading