+ { showGoogleSiteKit && siteKitConfiguration.isFeatureEnabled &&
}
{ features.indexables && features.seoAnalysis && (
) }
diff --git a/packages/js/src/dashboard/components/site-kit-setup-widget.js b/packages/js/src/dashboard/components/site-kit-setup-widget.js
new file mode 100644
index 00000000000..b7dc072140f
--- /dev/null
+++ b/packages/js/src/dashboard/components/site-kit-setup-widget.js
@@ -0,0 +1,158 @@
+import { Button, Paper, Stepper, Title, DropdownMenu } from "@yoast/ui-library";
+import { ReactComponent as YoastConnectSiteKit } from "../../../images/yoast-connect-google-site-kit.svg";
+import { __ } from "@wordpress/i18n";
+import { CheckCircleIcon } from "@heroicons/react/solid";
+import { ArrowRightIcon, XIcon, TrashIcon } from "@heroicons/react/outline";
+
+/**
+ * Get the button and stepper props based on the current state.
+ *
+ * @param {boolean} isInstalled Whether the plugin is isInstalled.
+ * @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.
+ *
+ * @returns {Object} The button and stepper props.
+ */
+const getButtonAndStepperProps = ( isInstalled, isActive, isSetupCompleted, isConnected, installUrl, activateUrl, setupUrl ) => {
+ let buttonProps;
+ let currentStep;
+ let isComplete = false;
+
+ switch ( true ) {
+ case ( ! isInstalled ):
+ currentStep = 1;
+ buttonProps = {
+ children: __( "Install Site Kit by Google", "wordpress-seo" ),
+ as: "a",
+ href: installUrl,
+ };
+ break;
+ case ( ! isActive ):
+ currentStep = 2;
+ buttonProps = {
+ children: __( "Activate Site Kit by Google", "wordpress-seo" ),
+ as: "a",
+ href: activateUrl,
+ };
+ break;
+ case ( ! isSetupCompleted ):
+ currentStep = 3;
+ buttonProps = {
+ children: __( "Set up Site Kit by Google", "wordpress-seo" ),
+ as: "a",
+ href: setupUrl,
+ };
+ break;
+ case ( ! isConnected ):
+ currentStep = 4;
+ buttonProps = { children: __( "Connect Site Kit by Google", "wordpress-seo" ) };
+ break;
+ case isConnected:
+ isComplete = true;
+ currentStep = 4;
+ buttonProps = { children: "Take a quick tour" };
+ break;
+ }
+ return { buttonProps, currentStep, isComplete };
+};
+
+const steps = [
+ __( "INSTALL", "wordpress-seo" ),
+ __( "ACTIVATE", "wordpress-seo" ),
+ __( "SET UP", "wordpress-seo" ),
+ __( "CONNECT", "wordpress-seo" ),
+];
+
+/**
+ * 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.
+ *
+ * @returns {JSX.Element} The widget.
+ */
+export const SiteKitSetupWidget = ( {
+ installUrl,
+ activateUrl,
+ setupUrl,
+ isConnected,
+ isActive,
+ isSetupCompleted,
+ isInstalled,
+ onRemove,
+ onRemovePermanently,
+ learnMorelink,
+} ) => {
+ const { buttonProps, currentStep, isComplete } = getButtonAndStepperProps(
+ isInstalled, isActive, isSetupCompleted, isConnected, installUrl, activateUrl, setupUrl );
+ return
+
+
+
+
+
+ { __( "Remove until next visit", "wordpress-seo" ) }
+
+
+
+ { __( "Remove permanently", "wordpress-seo" ) }
+
+
+
+
+
+
+ { steps.map( ( label, index ) => ( index + 1 || isComplete }
+ /> ) ) }
+
+
+ { __( "Expand your dashboard with insights from Google!", "wordpress-seo" ) }
+ { __( "Bring together powerful tools like Google Analytics and Search Console for a complete overview of your website's performance, all in one seamless dashboard.", "wordpress-seo" ) }
+
+ { __( "What you'll get:", "wordpress-seo" ) }
+
+
+
+ { __( "Actionable insights into traffic, SEO, and user behavior to grow your audience.", "wordpress-seo" ) }
+
+
+
+ { __( "Key performance metrics to fine-tune your website and optimize like a pro.", "wordpress-seo" ) }
+
+
+
+
+
+ { isConnected ?
{ __( "Dismiss", "wordpress-seo" ) }
+ :
+ { __( "Learn more", "wordpress-seo" ) }
+
+
+ }
+
+ ;
+};
+
+
diff --git a/packages/js/src/integrations-page/recommended-integrations.js b/packages/js/src/integrations-page/recommended-integrations.js
index fa8ddd68b43..4ecd436bdf9 100644
--- a/packages/js/src/integrations-page/recommended-integrations.js
+++ b/packages/js/src/integrations-page/recommended-integrations.js
@@ -81,14 +81,14 @@ const RecommendedIntegrations = [
const siteKitProps = {
isInstalled: get( window, "wpseoIntegrationsData.site_kit_configuration.isInstalled", false ),
isActive: get( window, "wpseoIntegrationsData.site_kit_configuration.isActive", false ),
- afterSetup: get( window, "wpseoIntegrationsData.site_kit_configuration.setup_completed", false ),
+ afterSetup: get( window, "wpseoIntegrationsData.site_kit_configuration.isSetupCompleted", false ),
isConnected: get( window, "wpseoIntegrationsData.site_kit_configuration.isConnected", false ),
- installUrl: get( window, "wpseoIntegrationsData.site_kit_configuration.install_url", "" ),
- activateUrl: get( window, "wpseoIntegrationsData.site_kit_configuration.activate_url", "" ),
- setupUrl: get( window, "wpseoIntegrationsData.site_kit_configuration.setup_url", "" ),
+ installUrl: get( window, "wpseoIntegrationsData.site_kit_configuration.installUrl", "" ),
+ activateUrl: get( window, "wpseoIntegrationsData.site_kit_configuration.activateUrl", "" ),
+ setupUrl: get( window, "wpseoIntegrationsData.site_kit_configuration.setupUrl", "" ),
};
-const isSiteKitFeatureEnabled = get( window, "wpseoIntegrationsData.site_kit_configuration.feature_enabled", false );
+const isSiteKitFeatureEnabled = get( window, "wpseoIntegrationsData.site_kit_configuration.isFeatureEnabled", false );
if ( isSiteKitFeatureEnabled ) {
RecommendedIntegrations.push(
);
}
diff --git a/packages/js/src/integrations-page/site-kit-integration.js b/packages/js/src/integrations-page/site-kit-integration.js
index c62ef14313d..b77556bab03 100644
--- a/packages/js/src/integrations-page/site-kit-integration.js
+++ b/packages/js/src/integrations-page/site-kit-integration.js
@@ -49,7 +49,7 @@ const SuccessfullyConnected = () => {
* The Site Kit integration component.
*
* @param {boolean} isActive Whether the integration is active.
- * @param {boolean} afterSetup Whether the integration has been set up.
+ * @param {boolean} isSetupCompleted Whether the integration has been set up.
* @param {boolean} isInstalled Whether the integration is installed.
* @param {boolean} isConnected Whether the integration is connected.
* @param {string} installUrl The installation url.
@@ -58,7 +58,7 @@ const SuccessfullyConnected = () => {
*
* @returns {WPElement} The Site Kit integration component.
*/
-export const SiteKitIntegration = ( { isActive, afterSetup, isInstalled, isConnected, installUrl, activateUrl, setupUrl } ) => {
+export const SiteKitIntegration = ( { isActive, isSetupCompleted, isInstalled, isConnected, installUrl, activateUrl, setupUrl } ) => {
const [ isModalOpen, toggleModal ] = useToggleState( false );
const [ isDisconnectModalOpen, toggleDisconnectModal ] = useToggleState( false );
@@ -77,7 +77,7 @@ export const SiteKitIntegration = ( { isActive, afterSetup, isInstalled, isConne
href: activateUrl,
};
}
- if ( ! afterSetup ) {
+ if ( ! isSetupCompleted ) {
return {
children: __( "Set up Site Kit by Google", "wordpress-seo" ),
as: "a",
@@ -98,10 +98,10 @@ export const SiteKitIntegration = ( { isActive, afterSetup, isInstalled, isConne
variant: "secondary",
onClick: toggleDisconnectModal,
};
- }, [ isInstalled, isActive, afterSetup, isConnected, installUrl, activateUrl, toggleModal ] );
+ }, [ isInstalled, isActive, isSetupCompleted, isConnected, installUrl, activateUrl, toggleModal ] );
- const successfullyConnected = isInstalled && isActive && afterSetup && isConnected;
+ const successfullyConnected = isInstalled && isActive && isSetupCompleted && isConnected;
return (
<>
{ successfullyConnected && }
-
+
@@ -131,7 +131,7 @@ export const SiteKitIntegration = ( { isActive, afterSetup, isInstalled, isConne
SiteKitIntegration.propTypes = {
isActive: PropTypes.bool.isRequired,
- afterSetup: PropTypes.bool.isRequired,
+ isSetupCompleted: PropTypes.bool.isRequired,
isInstalled: PropTypes.bool.isRequired,
isConnected: PropTypes.bool.isRequired,
installUrl: PropTypes.string.isRequired,
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
new file mode 100644
index 00000000000..a4f16577837
--- /dev/null
+++ b/packages/js/tests/dashboard/components/site-kit-setup-widget.test.js
@@ -0,0 +1,58 @@
+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(
);
+ expect( screen.getByRole( "link", { name: /Install Site Kit by Google/i } ) ).toBeInTheDocument();
+ } );
+
+ it( "renders the widget with activate button", () => {
+ render(
);
+ expect( screen.getByRole( "link", { name: /Activate Site Kit by Google/i } ) ).toBeInTheDocument();
+ } );
+
+ it( "renders the widget with setup button", () => {
+ render(
);
+ expect( screen.getByRole( "link", { name: /Set up Site Kit by Google/i } ) ).toBeInTheDocument();
+ } );
+
+ 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 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 menu/i } ) );
+ const removeButton = screen.getByRole( "menuitem", { name: /Remove permanently/i, type: "button" } );
+ fireEvent.click( removeButton );
+ expect( defaultProps.onRemovePermanently ).toHaveBeenCalled();
+ } );
+} );
diff --git a/packages/js/tests/integrations-page/SiteKitIntegration.test.js b/packages/js/tests/integrations-page/SiteKitIntegration.test.js
index c03308d8dbd..09da44f4383 100644
--- a/packages/js/tests/integrations-page/SiteKitIntegration.test.js
+++ b/packages/js/tests/integrations-page/SiteKitIntegration.test.js
@@ -1,10 +1,11 @@
-import { render, screen, act } from "../test-utils";
+import { render, screen } from "../test-utils";
import { SiteKitIntegration } from "../../src/integrations-page/site-kit-integration";
jest.mock( "@wordpress/data", () => ( {
useSelect: jest.fn( () => [] ),
} ) );
+
describe( "SiteKitIntegration", () => {
const urlsProps = {
installUrl: "/wp-admin/update.php?action=install-plugin&plugin=google-site-kit&_wpnonce=8b2868f15d",
@@ -14,7 +15,7 @@ describe( "SiteKitIntegration", () => {
it( "renders the integration component", () => {
render(
{
[ "not installed, not active, not after setup, and not connected", false, false, false, false ],
[ "not installed, not active, after setup, and not connected", false, false, true, false ],
[ "not installed, not active, after setup, and connected", false, false, true, true ],
- ] )( "shows 'Install Site Kit by Google' link when not installed when %s", ( _title, isInstalled, isActive, afterSetup, isConnected ) => {
+ ] )( "shows 'Install Site Kit by Google' link when not installed when %s", ( _title, isInstalled, isActive, isSetupCompleted, isConnected ) => {
render( {
[ "installed, not active, not after setup, and not connected", true, false, false, false ],
[ "installed, not active, after setup, and not connected", true, false, true, false ],
[ "installed, not active, after setup, and connected", true, false, true, true ],
- ] )( "shows 'Activate Site Kit by Google' button when installed but not active when %s", ( _title, isInstalled, isActive, afterSetup, isConnected ) => {
+ ] )( "shows 'Activate Site Kit by Google' button when installed but not active when %s", ( _title, isInstalled, isActive, isSetupCompleted, isConnected ) => {
render( {
} );
it( "shows 'Set up Site Kit by Google' button when active but not set up", () => {
- render( );
+ render( );
const link = screen.getByRole( "link", { name: "Set up Site Kit by Google" } );
expect( link ).toBeInTheDocument();
expect( link ).toHaveAttribute( "href", "/wp-admin/admin.php?page=googlesitekit-splash" );
} );
it( "shows 'Connect Site Kit by Google' button when set up but not connected", () => {
- render( );
+ render( );
expect( screen.getByRole( "button", { name: "Connect Site Kit by Google" } ) ).toBeInTheDocument();
} );
it( "shows 'Disconnect' button when connected", () => {
- render( );
+ render( );
expect( screen.getByRole( "button", { name: "Disconnect" } ) ).toBeInTheDocument();
expect( screen.getByText( "Successfully connected" ) ).toBeInTheDocument();
} );
diff --git a/packages/ui-library/.storybook/style.css b/packages/ui-library/.storybook/style.css
index 498e30b3a16..c07e0cb543a 100644
--- a/packages/ui-library/.storybook/style.css
+++ b/packages/ui-library/.storybook/style.css
@@ -39,6 +39,8 @@
@import "../src/components/textarea-field/style.css";
@import "../src/components/toggle-field/style.css";
@import "../src/components/tooltip-container/style.css";
+@import "../src/components/dropdown-menu/style.css";
+@import "../src/components/stepper/style.css";
@tailwind base;
@tailwind components;
diff --git a/packages/ui-library/src/components/dropdown-menu/docs/component.md b/packages/ui-library/src/components/dropdown-menu/docs/component.md
new file mode 100644
index 00000000000..eb6eeabcddb
--- /dev/null
+++ b/packages/ui-library/src/components/dropdown-menu/docs/component.md
@@ -0,0 +1,7 @@
+The dropdown menu component allows you to create a dropdown menu with a trigger and a list of items. The dropdown menu can be triggered by a button or an icon. The dropdown menu can be opened by clicking on the trigger. The dropdown menu can be closed by clicking outside the dropdown menu or by clicking on the trigger again.
+
+A dropdowm menu with sub components. The childrem on the dropdown menu should be the list of items `DropdownMenu.List` and the trigger `DropdownMenu.Trigger` or `DropdownMenu.IconTrigger`. The `DropdownMenu.List` should have the `DropdownMenu.Item` or `DropdownMenu.ButtonItem` as children.
+
+The `DropdownMenu.IconTrigger` component renders the trigger as an icon.
+The `DropdownMenu.Trigger` component is the trigger without any styling and with the render prop `open`.
+The `DropdownMenu.ButtonItem` component acts as a `Button`, while `DropdownMenu.Item` accepts `as` prop, without any styling, and render prop `open`.
\ No newline at end of file
diff --git a/packages/ui-library/src/components/dropdown-menu/docs/index.js b/packages/ui-library/src/components/dropdown-menu/docs/index.js
new file mode 100644
index 00000000000..a01392a1542
--- /dev/null
+++ b/packages/ui-library/src/components/dropdown-menu/docs/index.js
@@ -0,0 +1 @@
+export { default as component } from "./component.md";
diff --git a/packages/ui-library/src/components/dropdown-menu/index.js b/packages/ui-library/src/components/dropdown-menu/index.js
new file mode 100644
index 00000000000..4f2940262b7
--- /dev/null
+++ b/packages/ui-library/src/components/dropdown-menu/index.js
@@ -0,0 +1,128 @@
+import { DotsVerticalIcon } from "@heroicons/react/outline";
+import classNames from "classnames";
+import PropTypes from "prop-types";
+import React, { Fragment } from "react";
+import { Menu, Transition } from "@headlessui/react";
+import { Button } from "../../index";
+
+/**
+ * The item for the dropdown menu that renders as ui library Button with tertiary variant.
+ *
+ * @param {JSX.node} children Content of the button.
+ * @param {string} [className] CSS class.
+ *
+ * @returns {JSX.Element} Button item component.
+ */
+const ButtonItem = ( { children, className, ...props } ) => {
+ return (
+
+ { children }
+
+ );
+};
+
+ButtonItem.propTypes = {
+ children: PropTypes.node.isRequired,
+ className: PropTypes.string,
+};
+
+/**
+ * Dropdown menu icon trigger.
+ *
+ * @param {string} [className] CSS class.
+ * @param {string} [screenReaderTriggerLabel] Screen reader label for the menu trigger.
+ * @param {JSX.node} [Icon] Icon component.
+ *
+ * @returns {JSX.Element} Menu trigger component.
+ */
+const IconTrigger = ( { className, screenReaderTriggerLabel, Icon = DotsVerticalIcon, ...props } ) => (
+
+ { ( { open } ) => <>
+
+ { screenReaderTriggerLabel }
+ > }
+
+);
+
+IconTrigger.propTypes = {
+ className: PropTypes.string,
+ screenReaderTriggerLabel: PropTypes.string.isRequired,
+ Icon: PropTypes.node,
+};
+
+/**
+ * Dropdown menu list.
+ *
+ * @param {JSX.node} children Content of the menu.
+ * @param {string} [className] CSS class.
+ *
+ * @returns {JSX.Element} Menu list component.
+ */
+const List = ( { children, className, ...props } ) => {
+ return (
+
+
+ { children }
+
+
+ );
+};
+
+List.propTypes = {
+ children: PropTypes.node.isRequired,
+ className: PropTypes.string,
+};
+
+/**
+ *
+ * @param {JSX.node} children Content of the menu.
+ * @param {object} props The menu props.
+ *
+ * @returns {JSX.Element} Dropdown menu component.
+ */
+export const DropdownMenu = ( { children, ...props } ) => {
+ return (
+
+ { children }
+
+ );
+};
+
+DropdownMenu.propTypes = {
+ children: PropTypes.node.isRequired,
+};
+
+DropdownMenu.Item = Menu.Item;
+DropdownMenu.Item.displayName = "DropdownMenu.Item";
+
+DropdownMenu.ButtonItem = ButtonItem;
+DropdownMenu.ButtonItem.displayName = "DropdownMenu.ButtonItem";
+
+DropdownMenu.IconTrigger = IconTrigger;
+DropdownMenu.IconTrigger.displayName = "DropdownMenu.IconTrigger";
+
+DropdownMenu.Trigger = Menu.Button;
+DropdownMenu.Trigger.displayName = "DropdownMenu.Trigger";
+
+DropdownMenu.List = List;
+DropdownMenu.List.displayName = "DropdownMenu.List";
+
+DropdownMenu.displayName = "DropdownMenu";
diff --git a/packages/ui-library/src/components/dropdown-menu/stories.js b/packages/ui-library/src/components/dropdown-menu/stories.js
new file mode 100644
index 00000000000..7adf2faf2a4
--- /dev/null
+++ b/packages/ui-library/src/components/dropdown-menu/stories.js
@@ -0,0 +1,47 @@
+import { DropdownMenu } from ".";
+import React from "react";
+import { XIcon, TrashIcon } from "@heroicons/react/outline";
+import { component } from "./docs";
+
+export const Factory = {
+ render: () =>
+
+
+
+ Item
+
+
+ Item
+
+
+
+ Button Item
+
+
+
+ Button Item
+
+
+ ,
+};
+
+
+export default {
+ title: "2) Components/DropdownMenu",
+ component: DropdownMenu,
+ argTypes: {
+ children: { control: "text" },
+ },
+ parameters: {
+ docs: {
+ description: { component },
+ },
+ },
+ decorators: [
+ ( Story ) => (
+
+
+
+ ),
+ ],
+};
diff --git a/packages/ui-library/src/components/dropdown-menu/style.css b/packages/ui-library/src/components/dropdown-menu/style.css
new file mode 100644
index 00000000000..40bb35933c5
--- /dev/null
+++ b/packages/ui-library/src/components/dropdown-menu/style.css
@@ -0,0 +1,37 @@
+@layer components {
+ .yst-root {
+ .yst-dropdown-menu__icon-trigger {
+ @apply
+ yst-text-slate-400
+ yst-rounded-full
+ focus:yst-text-slate-600
+ focus:yst-outline
+ focus:yst-outline-2
+ focus:yst-outline-offset-2
+ focus:yst-outline-primary-500;
+ }
+
+ .yst-dropdown-menu__item--button {
+ @apply
+ yst-items-center
+ yst-w-full
+ yst-ring-0
+ yst-rounded-none
+ focus:yst-outline-none
+ focus:yst-bg-slate-100
+ hover:yst-bg-slate-100
+ yst-outline-none;
+ }
+
+ .yst-dropdown-menu__list {
+ @apply
+ yst-rounded-md
+ yst-border
+ yst-border-slate-200
+ yst-shadow-sm
+ yst-bg-white
+ yst-z-50
+ focus-visible:yst-outline-none;
+ }
+ }
+}
\ No newline at end of file
diff --git a/packages/ui-library/src/components/stepper/docs/component.md b/packages/ui-library/src/components/stepper/docs/component.md
new file mode 100644
index 00000000000..3abcddd17fc
--- /dev/null
+++ b/packages/ui-library/src/components/stepper/docs/component.md
@@ -0,0 +1 @@
+The stepper element takes takes the number of steps and current step and has Step component to render each step.
\ No newline at end of file
diff --git a/packages/ui-library/src/components/stepper/docs/index.js b/packages/ui-library/src/components/stepper/docs/index.js
new file mode 100644
index 00000000000..a01392a1542
--- /dev/null
+++ b/packages/ui-library/src/components/stepper/docs/index.js
@@ -0,0 +1 @@
+export { default as component } from "./component.md";
diff --git a/packages/ui-library/src/components/stepper/index.js b/packages/ui-library/src/components/stepper/index.js
new file mode 100644
index 00000000000..c868c8b7bf2
--- /dev/null
+++ b/packages/ui-library/src/components/stepper/index.js
@@ -0,0 +1,115 @@
+import classNames from "classnames";
+import PropTypes from "prop-types";
+import React, { forwardRef, useRef, useEffect, useState, useCallback, createContext, useContext } from "react";
+import { CheckIcon } from "@heroicons/react/solid";
+import { ProgressBar } from "../../index";
+import { noop } from "lodash";
+
+/**
+ * Context for the stepper. Used to add a reference to the step.
+ */
+const StepperContext = createContext( {
+ addStepRef: noop,
+} );
+
+/**
+ * Step component.
+ *
+ * @param {string} label The step label.
+ * @param {boolean} isComplete Is the step complete.
+ * @param {boolean} isActive Is the step
+ *
+ * @returns {JSX.Element} The step element.
+ */
+const Step = ( { label, isComplete, isActive } ) => {
+ const { addStepRef } = useContext( StepperContext );
+ return (
+
+
+ { isComplete &&
}
+
+
+
+
{ label }
+
+ );
+};
+
+Step.displayName = "Step";
+Step.propTypes = {
+ label: PropTypes.string.isRequired,
+ isActive: PropTypes.bool.isRequired,
+ isComplete: PropTypes.bool.isRequired,
+};
+
+/**
+ *
+ * @param {JSX.Node} children Content of the stepper.
+ * @param {number} [currentStep] The current step.
+ * @param {string} [className] Optional extra className.
+ *
+ * @returns {JSX.Element} The Stepper element.
+ */
+export const Stepper = forwardRef( ( { children, currentStep, className = "" }, ref ) => {
+ const [ progressBarPosition, setProgressBarPosition ] = useState( {
+ left: 0,
+ right: 0,
+ } );
+ const stepRef = useRef( [] );
+
+ useEffect( () => {
+ if ( stepRef.current.length > 0 ) {
+ const firstStepRect = stepRef.current[ 0 ].getBoundingClientRect();
+ const lastStepRect = stepRef.current[ stepRef.current.length - 1 ].getBoundingClientRect();
+ setProgressBarPosition( {
+ left: firstStepRect.width / 2,
+ right: lastStepRect.width / 2,
+ } );
+ }
+ }, [ stepRef.current ] );
+
+ const addStepRef = useCallback( ( el ) => ( stepRef.current.push( el ) ), [ stepRef.current ] );
+
+ return (
+
+
+
+ );
+} );
+
+Stepper.displayName = "Stepper";
+Stepper.propTypes = {
+ currentStep: PropTypes.number.isRequired,
+ children: PropTypes.node.isRequired,
+ className: PropTypes.string,
+};
+Stepper.defaultProps = {
+ className: "",
+};
+
+Stepper.Step = Step;
+Stepper.Context = StepperContext;
+Stepper.Step.displayName = "Stepper.Step";
+
diff --git a/packages/ui-library/src/components/stepper/stories.js b/packages/ui-library/src/components/stepper/stories.js
new file mode 100644
index 00000000000..9acfcf070f9
--- /dev/null
+++ b/packages/ui-library/src/components/stepper/stories.js
@@ -0,0 +1,66 @@
+import React, { useState, useCallback } from "react";
+import { useArgs } from "@storybook/preview-api";
+import { Stepper } from ".";
+import { component } from "./docs";
+import { Button } from "../../index";
+
+export const Factory = {
+ parameters: {
+ controls: { disable: false },
+ },
+ render: ( args ) =>{
+ const [ isComplete, setIsComplete ] = useState( false );
+ const steps = [ "INSTALL", "ACTIVATE", "SET UP", "CONNECT" ];
+ const [ { className, currentStep }, updateArgs ] = useArgs();
+
+ const handleNext = useCallback( () => {
+ if ( currentStep < steps.length ) {
+ setIsComplete( false );
+ updateArgs( { currentStep: currentStep + 1 } );
+ } else if ( currentStep === steps.length && ! isComplete ) {
+ setIsComplete( true );
+ } else if ( isComplete ) {
+ setIsComplete( false );
+ updateArgs( { currentStep: 1 } );
+ }
+ }, [ setIsComplete, updateArgs, isComplete, currentStep ] );
+
+ return <>
+
+ { steps.map( ( step, index ) => index + 1 || isComplete }
+ isActive={ currentStep === index + 1 }
+ /> ) }
+
+
+
+
+ { currentStep < steps.length && "Next" }
+ { currentStep === steps.length && ! isComplete && "Finish" }
+ { isComplete && "Restart" }
+
+ >;
+ },
+};
+
+export default {
+ title: "2) Components/Stepper",
+ component: Stepper,
+ argTypes: {
+ className: { control: "text" },
+ numberOfSteps: { control: "number" },
+ currentStep: { control: "number" },
+ },
+ parameters: {
+ docs: {
+ description: { component },
+ },
+ },
+ args: {
+ className: "yst-mb-5",
+ numberOfSteps: 4,
+ currentStep: 1,
+ },
+};
diff --git a/packages/ui-library/src/components/stepper/style.css b/packages/ui-library/src/components/stepper/style.css
new file mode 100644
index 00000000000..139e0a3eea8
--- /dev/null
+++ b/packages/ui-library/src/components/stepper/style.css
@@ -0,0 +1,57 @@
+@layer components {
+ .yst-root {
+
+ .yst-stepper {
+ @apply yst-relative yst-flex yst-justify-between yst-items-center;
+
+ .yst-progress-bar__progress {
+ @apply yst-duration-500;
+ }
+
+ }
+
+ .yst-step {
+ @apply yst-flex yst-flex-col yst-items-center;
+ }
+
+ .yst-step__circle {
+ @apply yst-bg-white
+ yst-ring-slate-300
+ yst-w-6
+ yst-h-6
+ yst-ring-2
+ yst-rounded-full
+ yst-z-10
+ yst-relative;
+ }
+
+ .yst-step__icon {
+ @apply yst-absolute yst-top-1/2 yst-left-1/2 yst-transform -yst-translate-x-1/2 -yst-translate-y-1/2;
+ }
+
+ .yst-step--active {
+ @apply yst-text-primary-500;
+
+ .yst-step__circle {
+ @apply
+ yst-bg-white
+ yst-text-white
+ yst-ring-primary-500
+ yst-transition-all
+ yst-ease-in
+ yst-delay-500;
+ }
+
+ .yst-step__icon{
+ @apply yst-transition-all yst-ease-in;
+ }
+ }
+
+ .yst-step--complete {
+ @apply yst-text-slate-900;
+ .yst-step__circle {
+ @apply yst-bg-primary-500 yst-text-white yst-ring-primary-500 yst-transition-none yst-delay-0;
+ }
+ }
+ }
+}
diff --git a/packages/ui-library/src/index.js b/packages/ui-library/src/index.js
index 830ac850945..1c4c9c962a8 100644
--- a/packages/ui-library/src/index.js
+++ b/packages/ui-library/src/index.js
@@ -41,6 +41,8 @@ export { default as TextField } from "./components/text-field";
export { default as TextareaField } from "./components/textarea-field";
export { default as ToggleField } from "./components/toggle-field";
export { TooltipContainer, TooltipTrigger, TooltipWithContext, useTooltipContext } from "./components/tooltip-container";
+export { DropdownMenu } from "./components/dropdown-menu";
+export { Stepper } from "./components/stepper";
export * from "./hooks";
export * from "./constants";
diff --git a/src/dashboard/application/configuration/dashboard-configuration.php b/src/dashboard/application/configuration/dashboard-configuration.php
index eae738da4a0..9ad97d3c2e0 100644
--- a/src/dashboard/application/configuration/dashboard-configuration.php
+++ b/src/dashboard/application/configuration/dashboard-configuration.php
@@ -4,13 +4,16 @@
// 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;
use Yoast\WP\SEO\Dashboard\Infrastructure\Nonces\Nonce_Repository;
use Yoast\WP\SEO\Editors\Application\Analysis_Features\Enabled_Analysis_Features_Repository;
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;
/**
@@ -60,6 +63,27 @@ 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.
+ *
+ * @var Site_Kit
+ */
+ private $site_kit_integration_data;
+
/**
* The constructor.
*
@@ -67,10 +91,13 @@ class Dashboard_Configuration {
* @param Indexable_Helper $indexable_helper The indexable helper
* repository.
* @param User_Helper $user_helper The user helper.
- * @param Enabled_Analysis_Features_Repository $enabled_analysis_features_repository The analysis feature
- * repository.
+ * @param Enabled_Analysis_Features_Repository $enabled_analysis_features_repository The analysis feature.
+ * 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.
*/
public function __construct(
Content_Types_Repository $content_types_repository,
@@ -78,7 +105,10 @@ public function __construct(
User_Helper $user_helper,
Enabled_Analysis_Features_Repository $enabled_analysis_features_repository,
Endpoints_Repository $endpoints_repository,
- Nonce_Repository $nonce_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;
$this->indexable_helper = $indexable_helper;
@@ -86,6 +116,9 @@ 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;
}
/**
@@ -106,6 +139,7 @@ public function get_configuration(): array {
)->to_array(),
'endpoints' => $this->endpoints_repository->get_all_endpoints()->to_array(),
'nonce' => $this->nonce_repository->get_rest_nonce(),
+ 'siteKitConfiguration' => $this->site_kit_integration_data->to_array(),
];
}
}
diff --git a/src/dashboard/infrastructure/integrations/site-kit.php b/src/dashboard/infrastructure/integrations/site-kit.php
index 33291a16cb7..bf7b93920a1 100644
--- a/src/dashboard/infrastructure/integrations/site-kit.php
+++ b/src/dashboard/infrastructure/integrations/site-kit.php
@@ -61,14 +61,14 @@ public function to_array(): array {
$site_kit_setup_url = \self_admin_url( 'admin.php?page=googlesitekit-splash' );
return [
- 'isInstalled' => \file_exists( \WP_PLUGIN_DIR . '/' . self::SITE_KIT_FILE ),
- 'isActive' => \is_plugin_active( self::SITE_KIT_FILE ),
- 'setup_completed' => \get_option( 'googlesitekit_has_connected_admins', false ) === '1',
- 'isConnected' => $this->options_helper->get( 'google_site_kit_connected', false ),
- 'feature_enabled' => ( new Google_Site_Kit_Feature_Conditional() )->is_met(),
- 'install_url' => $site_kit_install_url,
- 'activate_url' => $site_kit_activate_url,
- 'setup_url' => $site_kit_setup_url,
+ 'isInstalled' => \file_exists( \WP_PLUGIN_DIR . '/' . self::SITE_KIT_FILE ),
+ 'isActive' => \is_plugin_active( self::SITE_KIT_FILE ),
+ 'isSetupCompleted' => \get_option( 'googlesitekit_has_connected_admins', false ) === '1',
+ 'isConnected' => $this->options_helper->get( 'google_site_kit_connected', false ),
+ 'isFeatureEnabled' => ( new Google_Site_Kit_Feature_Conditional() )->is_met(),
+ 'installUrl' => $site_kit_install_url,
+ 'activateUrl' => $site_kit_activate_url,
+ 'setupUrl' => $site_kit_setup_url,
];
}
diff --git a/tests/Unit/Integrations/Admin/Integrations_Page_Integration_Test.php b/tests/Unit/Integrations/Admin/Integrations_Page_Integration_Test.php
index ddcfd212669..73668200bca 100644
--- a/tests/Unit/Integrations/Admin/Integrations_Page_Integration_Test.php
+++ b/tests/Unit/Integrations/Admin/Integrations_Page_Integration_Test.php
@@ -160,14 +160,14 @@ public function test_enqueue_assets() {
$this->elementor_conditional->expects( 'is_met' )->andReturnFalse();
$this->jetpack_conditional->expects( 'is_met' )->andReturnFalse();
$site_kit_config = [
- 'isInstalled' => false,
- 'isActive' => false,
- 'setup_completed' => false,
- 'isConnected' => false,
- 'feature_enabled' => false,
- 'install_url' => 'example.com',
- 'activate_url' => 'example.com',
- 'setup_url' => 'example.com',
+ 'isInstalled' => false,
+ 'isActive' => false,
+ 'isSetupCompleted' => false,
+ 'isConnected' => false,
+ 'isFeatureEnabled' => false,
+ 'installUrl' => 'example.com',
+ 'activateUrl' => 'example.com',
+ 'setupUrl' => 'example.com',
];
$this->site_kit_configuration->expects( 'to_array' )->andReturn( $site_kit_config );