Skip to content

Connect inline banner to the content suggestions modal flow#23156

Merged
vraja-pro merged 19 commits intofeature/content-plannerfrom
Connect-inline-banner-to-display-the-content-suggestion-modal
Apr 14, 2026
Merged

Connect inline banner to the content suggestions modal flow#23156
vraja-pro merged 19 commits intofeature/content-plannerfrom
Connect-inline-banner-to-display-the-content-suggestion-modal

Conversation

@JorPV
Copy link
Copy Markdown
Contributor

@JorPV JorPV commented Apr 13, 2026

Context

The inline outline banner (yoast/content-planner-banner block) auto-inserts on new empty posts with a "Get content suggestions" button that was not wired to any action. This PR connects that button to the existing FeatureModal flow (Suggestions → Outline → Add blocks) as per de design, creating a shared modal architecture controlled via a WordPress data store that works across the block editor iframe boundary.

Summary

  • Connects the inline banner to the content suggestions modal flow.

Relevant technical choices

  • WordPress data store for cross-iframe communication: The block editor renders blocks inside an iframe, but @wordpress/data stores are shared across the iframe boundary. A lightweight store was chosen over DOM events or React context, which don't cross the iframe.
  • Single shared FeatureModal instance: Both the sidebar button and inline banner dispatch to the same store, which the ContentPlannerEditorPlugin subscribes to. This prevents two modal instances from opening simultaneously.
  • getOutlineHandler helper: Extracted to a standalone function to keep the FeatureModal component's cyclomatic complexity within the ESLint maximum of 6.
  • isEmptyCanvas renamed to isEmptyPost: Renamed across all components and tests to align with the existing isNewPost naming convention. The prop is used to skip the replace confirmation when the post is empty — semantically correct since the banner only appears on empty posts.
  • onAddOutline is still a stub: The actual block insertion logic will come from PR 1101 content planner create the content suggestions blocks #23117. Currently, onAddOutline only removes the banner block from the editor.

Test instructions for the acceptance test before the PR gets merged

  1. Go to Posts > Add New to create a new post.
  2. Verify the inline banner appears below the first paragraph placeholder with the text "Stuck on what to write next?" and a "Get content suggestions" button.
  3. Click the "Get content suggestions" button on the inline banner.
  4. Verify the modal opens directly to the content suggestions loading view (skeleton loaders), without showing the "Looking for inspiration?" approve screen first.
  5. Wait for suggestions to load, then click on a suggestion.
  6. Verify the Content outline modal appears.
  7. Click "Add outline to post".
  8. Verify the modal closes directly — no "Replace existing content?" confirmation is shown.
  9. Verify the inline banner block is removed from the editor.
  10. Create another new post. This time, open the Yoast SEO sidebar or metabox and click the "Get content suggestions" button there.
  11. Verify the modal opens with the "Looking for inspiration?" approve screen first (the existing flow is unchanged).
  12. Click through: approve → suggestions → outline.
  13. Close the modal without applying the outline — verify the inline banner remains visible in the editor.
  14. Dismiss the inline banner via the X button — verify the block is removed.

Relevant test scenarios

  • Changes should be tested with the browser console open
  • Changes should be tested on different editors (Default Block/Gutenberg/Classic/Elementor/other)

The inline banner only appears in the Block Editor. Verify the sidebar button still works correctly in both Block Editor and Elementor.

Test instructions for QA when the code is in the RC

  • QA should use the same steps as above.

Impact check

  • Content Planner feature (inline banner, feature modal, sidebar button)
  • Block editor integration (ContentPlannerEditorPlugin)
  • @wordpress/data store registration

Other environments

  • This PR also affects Shopify. I have added a changelog entry starting with [shopify-seo], added test instructions for Shopify and attached the Shopify label to this PR.
  • This PR also affects Yoast SEO for Google Docs. I have added a changelog entry starting with [yoast-doc-extension], added test instructions for Yoast SEO for Google Docs and attached the Google Docs Add-on label to this PR.

Documentation

  • I have written documentation for this change.

Quality assurance

  • I have tested this code to the best of my abilities.
  • During testing, I had activated all plugins that Yoast SEO provides integrations for.
  • I have added unit tests to verify the code works as intended.
  • If any part of the code is behind a feature flag, my test instructions also cover cases where the feature flag is switched off.
  • I have written this PR in accordance with my team's definition of done.
  • I have checked that the base branch is correctly set.
  • I have run grunt build:images and commited the results, if my PR introduces new images or SVGs.

Innovation

  • No innovation project is applicable for this PR.
  • This PR falls under an innovation project. I have attached the innovation label.
  • I have added my hours to the WBSO document.

Fixes https://github.com/Yoast/reserved-tasks/issues/1147

@JorPV JorPV added the changelog: non-user-facing Needs to be included in the 'Non-userfacing' category in the changelog label Apr 13, 2026
@JorPV JorPV requested a review from Copilot April 13, 2026 08:30
@JorPV JorPV added this to the feature/content-planner milestone Apr 13, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Connects the inline Content Planner banner’s “Get content suggestions” CTA to the existing Content Planner modal flow by centralizing modal state in a shared WordPress data store (usable across the block editor iframe boundary).

Changes:

  • Introduces a yoast-seo/content-planner WP data store to control FeatureModal open/close state and whether to skip the approve step.
  • Refactors the modal architecture so both the sidebar button and inline banner dispatch into the same shared modal instance (rendered via ContentPlannerEditorPlugin).
  • Updates/extends unit tests around modal flow behavior, initialization, and store state.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
packages/js/src/ai-content-planner/store/index.js Adds a small WP data store for FeatureModal UI state (open + skipApprove).
packages/js/src/ai-content-planner/constants.js Introduces FEATURE_MODAL_STORE constant for the store name.
packages/js/src/ai-content-planner/initialize.js Registers the store and renders a shared FeatureModal from the editor plugin; removes banner block after outline add (stub behavior).
packages/js/src/ai-content-planner/components/feature-modal.js Adds initialStatus support and reuses isEmptyCanvas to skip replace confirmation; extracts getOutlineHandler.
packages/js/src/ai-content-planner/block.js Wires inline banner button to openModal(true) (skip approve) via the shared store.
packages/js/src/ai-content-planner/components/inline-banner.js Adds onClick prop and attaches it to the CTA button.
packages/js/src/ai-content-planner/components/content-planner-editor-item.js Changes sidebar button to dispatch openModal(false) instead of owning a local modal instance.
packages/js/src/ai-content-planner/containers/content-planner-editor-item.js Removes withSelect HOC and exports the component directly (modal state is now global).
packages/js/tests/ai-content-planner/components/feature-modal.test.js Extends tests to cover initialStatus and replace-confirmation skipping behavior.
packages/js/tests/ai-content-planner/initialize.test.js Updates mocks and adds assertions for rendering the modal based on store state.
packages/js/tests/ai-content-planner/store/index.test.js Adds store tests (but currently re-implements store logic inline).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +1 to +36
import { createReduxStore, createRegistry } from "@wordpress/data";
import { FEATURE_MODAL_STORE } from "../../../src/ai-content-planner/constants";

describe( "content planner store", () => {
let registry;

beforeEach( () => {
registry = createRegistry();
const store = createReduxStore( FEATURE_MODAL_STORE, {
reducer( state = { isOpen: false, skipApprove: false }, action ) {
switch ( action.type ) {
case "OPEN_MODAL":
return { ...state, isOpen: true, skipApprove: Boolean( action.skipApprove ) };
case "CLOSE_MODAL":
return { isOpen: false, skipApprove: false };
default:
return state;
}
},
actions: {
openModal( skipApprove = false ) {
return { type: "OPEN_MODAL", skipApprove };
},
closeModal() {
return { type: "CLOSE_MODAL" };
},
},
selectors: {
selectIsModalOpen( state ) {
return state.isOpen;
},
selectShouldSkipApprove( state ) {
return state.skipApprove;
},
},
} );
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

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

This test re-implements the store (reducer/actions/selectors) inline instead of importing the production store from src/ai-content-planner/store. As a result, it can pass even if the real store implementation changes or breaks. Consider exporting the store configuration (or a createStore helper) from the source module and registering that in the test registry so the tests exercise the actual code.

Suggested change
import { createReduxStore, createRegistry } from "@wordpress/data";
import { FEATURE_MODAL_STORE } from "../../../src/ai-content-planner/constants";
describe( "content planner store", () => {
let registry;
beforeEach( () => {
registry = createRegistry();
const store = createReduxStore( FEATURE_MODAL_STORE, {
reducer( state = { isOpen: false, skipApprove: false }, action ) {
switch ( action.type ) {
case "OPEN_MODAL":
return { ...state, isOpen: true, skipApprove: Boolean( action.skipApprove ) };
case "CLOSE_MODAL":
return { isOpen: false, skipApprove: false };
default:
return state;
}
},
actions: {
openModal( skipApprove = false ) {
return { type: "OPEN_MODAL", skipApprove };
},
closeModal() {
return { type: "CLOSE_MODAL" };
},
},
selectors: {
selectIsModalOpen( state ) {
return state.isOpen;
},
selectShouldSkipApprove( state ) {
return state.skipApprove;
},
},
} );
import { createRegistry } from "@wordpress/data";
import { FEATURE_MODAL_STORE } from "../../../src/ai-content-planner/constants";
import store from "../../../src/ai-content-planner/store";
describe( "content planner store", () => {
let registry;
beforeEach( () => {
registry = createRegistry();

Copilot uses AI. Check for mistakes.
* FeatureModal controlled by the content planner store.
*
* @returns {null} Renders nothing.
* @returns {JSX.Element|null} The FeatureModal when open, otherwise null.
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

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

The JSDoc for ContentPlannerEditorPlugin says it returns null when the modal is closed, but the component always returns a <FeatureModal ... /> element (with isOpen controlling its visibility). Update the return documentation to match the actual return value/behavior.

Suggested change
* @returns {JSX.Element|null} The FeatureModal when open, otherwise null.
* @returns {JSX.Element} The FeatureModal element, with visibility controlled by the isOpen prop.

Copilot uses AI. Check for mistakes.
Comment on lines +13 to +22
/**
* Returns the appropriate handler for adding an outline to the post.
* When the canvas is empty, skips the replace confirmation and applies directly.
*
* @param {boolean} isEmptyCanvas Whether the post has content or not.
* @param {Function} onConfirm The handler that applies the outline directly.
* @param {Function} onRequest The handler that shows the replace confirmation first.
* @returns {Function} The appropriate outline handler.
*/
const getOutlineHandler = ( isEmptyCanvas, onConfirm, onRequest ) => isEmptyCanvas ? onConfirm : onRequest;
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

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

getOutlineHandler’s JSDoc for isEmptyCanvas says "Whether the post has content or not", but the boolean is used as "canvas is empty" (true => skip confirmation). Please adjust the parameter description to avoid inverted/ambiguous meaning.

Copilot uses AI. Check for mistakes.
@coveralls
Copy link
Copy Markdown

coveralls commented Apr 13, 2026

Coverage Report for CI Build 96

Coverage increased (+0.009%) to 53.406%

Details

  • Coverage increased (+0.009%) from the base build.
  • Patch coverage: 14 uncovered changes across 5 files (38 of 52 lines covered, 73.08%).
  • 3 coverage regressions across 3 files.

Uncovered Changes

File Changed Covered %
packages/js/src/ai-content-planner/initialize.js 14 8 57.14%
packages/js/src/ai-content-planner/block.js 3 0 0.0%
packages/js/src/ai-content-planner/components/content-planner-editor-item.js 3 0 0.0%
packages/js/src/ai-content-planner/components/inline-banner.js 1 0 0.0%
packages/js/src/ai-content-planner/containers/content-planner-editor-item.js 1 0 0.0%

Coverage Regressions

3 previously-covered lines in 3 files lost coverage.

File Lines Losing Coverage Coverage
packages/js/src/ai-content-planner/containers/content-planner-editor-item.js 1 0.0%
packages/js/src/ai-content-planner/block.js 1 8.57%
packages/js/src/ai-content-planner/components/content-suggestion-block.js 1 0.0%

Coverage Stats

Coverage Status
Relevant Lines: 65577
Covered Lines: 34839
Line Coverage: 53.13%
Relevant Branches: 16850
Covered Branches: 9182
Branch Coverage: 54.49%
Branches in Coverage %: Yes
Coverage Strength: 45871.47 hits per line

💛 - Coveralls

JorPV added 2 commits April 13, 2026 10:52
…isEmptyPost' for improved clarity and consistency in modal handling
Copy link
Copy Markdown
Contributor

@vraja-pro vraja-pro left a comment

Choose a reason for hiding this comment

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

CR 🚧

*
* @type {string}
*/
export const FEATURE_MODAL_STORE = "yoast-seo/content-planner";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If it's a store for the content planner, should we name this constant as CONTENT_PLANNER_STORE ?

Will we need to to save the results from the requests in the store? For example, user can navigate from the outline back to the content suggestion and pick up a different suggestion. we can save it with useState in the feature modal file, just keeping in mind future usage of that store.

Copy link
Copy Markdown
Contributor Author

@JorPV JorPV Apr 13, 2026

Choose a reason for hiding this comment

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

We don't need to save request results in this store, the FeatureModal already handles that flow internally with useState indeed.

Once the blocks PR #23117 is merged, we will have two dedicated stores.

  • CONTENT_PLANNER_STORE → UI state (modal open/close, skipApprove, and potentially more UI flags)
  • yoast-seo/post-planner → data state (suggestions list, selected outline, API loading/error)

};
} ),
] )( ContentPlannerEditorItem );
export default ContentPlannerEditorItem;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
export default ContentPlannerEditorItem;
import { ContentPlannerEditorItem } from "../components/content-planner-editor-item";
import { compose } from "@wordpress/compose";
import { withDispatch } from "@wordpress/data";
import { FEATURE_MODAL_STORE } from "../constants";
export default compose( [
withDispatch( ( dispatch ) => {
const { openModal } = dispatch( FEATURE_MODAL_STORE );
return {
openModal,
};
} ),
] )( ContentPlannerEditorItem );

Then in the component you just pass it as a prop:

export const ContentPlannerEditorItem = ( { location, openModal } ) => {
      const handleClick = useCallback( () => {
      		openModal( false );
      	}, [ openModal ] );

Copy link
Copy Markdown
Contributor

@vraja-pro vraja-pro left a comment

Choose a reason for hiding this comment

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

CR 🚧

@github-actions
Copy link
Copy Markdown

A merge conflict has been detected for the proposed code changes in this PR. Please resolve the conflict by either rebasing the PR or merging in changes from the base branch.

…nnect-inline-banner-to-display-the-content-suggestion-modal
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/js/src/ai-content-planner/initialize.js
Comment thread packages/js/src/ai-content-planner/initialize.js
return;
}
}, [ isOpen ] );
setCameFromApproveModal( initialStatus === FEATURE_MODAL_STATUS.contentSuggestionsLoading );
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

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

When opening the modal with initialStatus === contentSuggestionsLoading (the skip-approve flow), cameFromApproveModal is set to true. That makes SuggestionsPanel apply the approve→suggestions cross-fade classes (including a 300ms delay), even though the approve step is being skipped, which can produce a brief empty modal before the skeleton loaders appear. For the skip-approve entry point, set cameFromApproveModal to false (or adjust the transition logic) so the loading view renders immediately.

Suggested change
setCameFromApproveModal( initialStatus === FEATURE_MODAL_STATUS.contentSuggestionsLoading );
setCameFromApproveModal( false );

Copilot uses AI. Check for mistakes.
Comment thread packages/js/src/ai-content-planner/store/modal.js
Comment thread packages/js/src/ai-content-planner/constants.js
@vraja-pro
Copy link
Copy Markdown
Contributor

CR 🚧 Consider store structre after #23117 was merged.

Comment thread packages/js/src/ai-content-planner/components/feature-modal.js
Copy link
Copy Markdown
Contributor

@vraja-pro vraja-pro left a comment

Choose a reason for hiding this comment

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

CR 🚧

  • I removed getSuggestionsEnterTransition
  • We need some cleanup of the modal store

@vraja-pro vraja-pro merged commit a6e5bd6 into feature/content-planner Apr 14, 2026
19 checks passed
@vraja-pro vraja-pro deleted the Connect-inline-banner-to-display-the-content-suggestion-modal branch April 14, 2026 08:20
@vraja-pro vraja-pro mentioned this pull request Apr 28, 2026
19 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changelog: non-user-facing Needs to be included in the 'Non-userfacing' category in the changelog

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants