• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Yoast / wordpress-seo / 0d0f7c9d72f61e7b981307e285d206dc9e26add3

18 Nov 2025 09:37AM UTC coverage: 53.381% (+0.1%) from 53.262%
0d0f7c9d72f61e7b981307e285d206dc9e26add3

Pull #22724

github

marinakoleva
Merge branch 'trunk' of https://github.com/Yoast/wordpress-seo into feature/off-the-bat-analysis
Pull Request #22724: Merge feature/off the bat analysis branch

8607 of 15888 branches covered (54.17%)

Branch coverage included in aggregate %.

215 of 215 new or added lines in 13 files covered. (100.0%)

3 existing lines in 2 files now uncovered.

32310 of 60763 relevant lines covered (53.17%)

47453.76 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

84.14
/packages/js/src/ai-optimizer/components/ai-optimize-button.js
1
import PropTypes from "prop-types";
2
import { __ } from "@wordpress/i18n";
3
import { useCallback, useRef, useState } from "@wordpress/element";
4
import { doAction } from "@wordpress/hooks";
5
import { useSelect, useDispatch } from "@wordpress/data";
6

7
/* Yoast dependencies */
8
import { IconAIFixesButton, SparklesIcon } from "@yoast/components";
9
import { Modal, useToggleState } from "@yoast/ui-library";
10
import { Paper } from "yoastseo";
11
import { get } from "lodash";
12

13
/* Internal dependencies */
14
import { ModalContent } from "./modal-content";
15
import { getAllBlocks } from "../../helpers/getAllBlocks";
16
import { LockClosedIcon } from "@heroicons/react/solid";
17
import { isTextViewActive } from "../../lib/tinymce";
18

19
/**
20
 * Returns the editor mode based on the editor type.
21
 * @returns {string} The editor mode, either "visual" or "text".
22
 */
23
const getEditorMode = () => {
2✔
24
        const editorType = useSelect( ( select ) => select( "yoast-seo/editor" ).getEditorType(), [] );
36✔
25

26
        if ( editorType === "blockEditor" ) {
36✔
27
                return useSelect( ( select ) => select( "core/edit-post" ).getEditorMode(), [] );
32✔
28
        } else if ( editorType === "classicEditor" ) {
4!
29
                return isTextViewActive() ? "text" : "visual";
4✔
30
        }
31
        return "";
×
32
};
33

34
/**
35
 * The AI Optimize button component.
36
 *
37
 * @param {string} id The assessment ID which AI Optimize should be applied to.
38
 * @param {boolean} [isPremium=false] Whether the Premium add-on is active.
39
 *
40
 * @returns {JSX.Element} The AI Optimize button.
41
 */
42
const AIOptimizeButton = ( { id, isPremium = false } ) => {
2!
43
        // The AI Optimize button ID is the same as the assessment ID, with "AIFixes" appended to it.
44
        // We continue to use "AIFixes" in the ID to keep it consistent with the Premium implementation.
45
        const aiOptimizeId = id + "AIFixes";
36✔
46
        const [ isModalOpen, , , setIsModalOpenTrue, setIsModalOpenFalse ] = useToggleState( false );
36✔
47
        const { activeMarker, activeAIButtonId, editorType, isWooSeoUpsellPost, keyphrase } = useSelect( ( select ) => ( {
36✔
48
                activeMarker: select( "yoast-seo/editor" ).getActiveMarker(),
49
                activeAIButtonId: select( "yoast-seo/editor" ).getActiveAIFixesButton(),
50
                editorType: select( "yoast-seo/editor" ).getEditorType(),
51
                isWooSeoUpsellPost: select( "yoast-seo/editor" ).getIsWooSeoUpsell(),
52
                keyphrase: select( "yoast-seo/editor" ).getFocusKeyphrase(),
53
        } ), [] );
54
        const editorMode = getEditorMode();
36✔
55

56
        const shouldShowUpsell = ! isPremium || isWooSeoUpsellPost;
36✔
57

58
        const { setActiveAIFixesButton, setActiveMarker, setMarkerPauseStatus, setMarkerStatus } = useDispatch( "yoast-seo/editor" );
36✔
59
        const focusElementRef = useRef( null );
36✔
60
        const [ buttonClass, setButtonClass ] = useState( "" );
36✔
61

62
        const defaultLabel = __( "Optimize with AI", "wordpress-seo" );
36✔
63
        const htmlLabel = __( "Please switch to the visual editor to optimize with AI.", "wordpress-seo" );
36✔
64

65
        // The button is pressed when the active AI button id is the same as the current button id.
66
        const isButtonPressed = activeAIButtonId === aiOptimizeId;
36✔
67

68
        // Determines if the button is enabled and what tooltip to show.
69
        // eslint-disable-next-line complexity
70
        const { isEnabled, ariaLabel } = useSelect( ( select ) => {
36✔
71
                // When Premium is not active (upsell), always show the generic tooltip
72
                if ( shouldShowUpsell ) {
36✔
73
                        // Gutenberg editor
74
                        if ( editorType === "blockEditor" ) {
6!
75
                                const blocks = getAllBlocks( select( "core/block-editor" ).getBlocks() );
6✔
76
                                const allVisual = editorMode === "visual" && blocks.every( block => select( "core/block-editor" ).getBlockMode( block.clientId ) === "visual" );
6✔
77
                                return {
6✔
78
                                        isEnabled: allVisual,
79
                                        ariaLabel: allVisual ? defaultLabel : htmlLabel,
3!
80
                                };
81
                        }
82
                        // Classic editor
UNCOV
83
                        return {
×
84
                                isEnabled: editorMode === "visual",
85
                                ariaLabel: editorMode === "visual" ? defaultLabel : htmlLabel,
×
86
                        };
87
                }
88
                // Editor mode
89
                if ( editorMode !== "visual" ) {
30✔
90
                        return {
4✔
91
                                isEnabled: false,
92
                                ariaLabel: htmlLabel,
93
                        };
94
                }
95

96
                // Block editor visual mode check
97
                if ( editorType === "blockEditor" ) {
26✔
98
                        const blocks = getAllBlocks( select( "core/block-editor" ).getBlocks() );
24✔
99
                        const allVisual = blocks.every( block => select( "core/block-editor" ).getBlockMode( block.clientId ) === "visual" );
24✔
100
                        if ( ! allVisual ) {
24✔
101
                                return {
2✔
102
                                        isEnabled: false,
103
                                        ariaLabel: htmlLabel,
104
                                };
105
                        }
106
                }
107

108
                // Check keyphrase specific assessments requirements
109
                const keyphraseAssessments = [ "introductionKeyword", "keyphraseDensity", "keyphraseDistribution" ];
24✔
110
                if ( keyphraseAssessments.includes( id ) ) {
24✔
111
                        const hasValidKeyphrase = !! keyphrase && keyphrase.trim().length > 0;
22✔
112
                        const collectData = get( window, "YoastSEO.analysis.collectData", false );
22✔
113
                        // Ensures the button uses the same analysis-ready content source, while staying safe if the analysis API hasn't initialized.
114
                        const editorData = collectData ? collectData() : {};
22!
115
                        const text = editorData?._text || "";
22✔
116
                        const hasContent = text.trim().length > 0;
22✔
117

118
                        // If missing the keyphrase or missing content, ask user to add both
119
                        if ( ! hasValidKeyphrase || ! hasContent ) {
22✔
120
                                return {
4✔
121
                                        isEnabled: false,
122
                                        ariaLabel: __( "Please add both a keyphrase and some text to your content.", "wordpress-seo" ),
123
                                };
124
                        }
125
                }
126

127
                // Check global disabled reasons (for unsupported content).
128
                const disabledAIButtons = select( "yoast-seo/editor" ).getDisabledAIFixesButtons();
20✔
129
                if ( Object.keys( disabledAIButtons ).includes( aiOptimizeId ) ) {
20✔
130
                        return {
2✔
131
                                isEnabled: false,
132
                                ariaLabel: disabledAIButtons[ aiOptimizeId ],
133
                        };
134
                }
135
                // Fallback for when all conditions above pass and the button is enabled.
136
                return {
18✔
137
                        isEnabled: true,
138
                        ariaLabel: defaultLabel,
139
                };
140
        }, [ isButtonPressed, activeAIButtonId, editorMode, id, keyphrase ] );
141

142
        /**
143
         * Handles the button press state.
144
         * @returns {void}
145
         */
146
        const handlePressedButton = () => {
36✔
147
                // If there is an active marker when the AI fixes button is clicked, remove it.
148
                if ( activeMarker ) {
6✔
149
                        setActiveMarker( null );
2✔
150
                        setMarkerPauseStatus( false );
2✔
151
                        // Remove highlighting from the editor.
152
                        window.YoastSEO.analysis.applyMarks( new Paper( "", {} ), [] );
2✔
153
                }
154

155
                /* If the current pressed button ID is the same as the active AI button id,
156
                we want to set the active AI button to null and enable back the highlighting button that was disabled
157
                when the AI button was pressed the first time. Otherwise, update the active AI button ID. */
158
                if ( aiOptimizeId === activeAIButtonId ) {
6✔
159
                        setActiveAIFixesButton( null );
4✔
160
                        // Enable the highlighting button when the AI button is not pressed.
161
                        setMarkerStatus( "enabled" );
4✔
162
                } else {
163
                        setActiveAIFixesButton( aiOptimizeId );
2✔
164
                        /*
165
                        Disable the highlighting button when the AI button is pressed.
166
                        This is because clicking on the highlighting button will remove the AI suggestion from the editor.
167
                         */
168
                        setMarkerStatus( "disabled" );
2✔
169
                }
170
                // Dismiss the tooltip when the button is pressed.
171
                setButtonClass( "" );
6✔
172
        };
173

174
        const handleClick = useCallback( () => {
36✔
175
                // eslint-disable-next-line no-negated-condition -- Let's handle the happy path first.
176
                if ( ! shouldShowUpsell ) {
6!
177
                        doAction( "yoast.ai.fixAssessments", aiOptimizeId );
6✔
178
                        /* Only handle the pressed button state in Premium.
179
                        We don't want to change the background color of the button and other styling when it's pressed in Free.
180
                        This is because clicking on the button in Free will open an upsell modal, and the button will not be in a pressed state. */
181
                        handlePressedButton();
6✔
182
                } else {
183
                        setIsModalOpenTrue();
×
184
                }
185
        }, [ handlePressedButton, setIsModalOpenTrue ] );
186

187
        // Add tooltip classes on mouse enter and remove them on mouse leave.
188
        const handleMouseEnter = useCallback( () => {
36✔
189
                if ( ariaLabel ) {
×
190
                        const direction = isEnabled ? "yoast-tooltip-w" : "yoast-tooltip-nw";
×
191
                        setButtonClass( `yoast-tooltip yoast-tooltip-multiline ${ direction }` );
×
192
                }
193
        }, [ isEnabled, ariaLabel ] );
194

195
        const handleMouseLeave = useCallback( () => {
36✔
196
                // Remove tooltip classes on mouse leave
197
                setButtonClass( "" );
×
198
        }, [] );
199

200
        return (
36✔
201
                <IconAIFixesButton
202
                        onClick={ handleClick }
203
                        ariaLabel={ ariaLabel }
204
                        onPointerEnter={ handleMouseEnter }
205
                        onPointerLeave={ handleMouseLeave }
206
                        id={ aiOptimizeId }
207
                        className={ `ai-button ${buttonClass}` }
208
                        pressed={ isButtonPressed }
209
                        disabled={ ! isEnabled }
210
                >
211
                        { shouldShowUpsell && <LockClosedIcon className="yst-fixes-button__lock-icon yst-text-amber-900" /> }
21✔
212
                        <SparklesIcon pressed={ isButtonPressed } />
213
                        {
214
                                isModalOpen && <Modal className="yst-introduction-modal" isOpen={ isModalOpen } onClose={ setIsModalOpenFalse } initialFocus={ focusElementRef }>
18!
215
                                        <Modal.Panel className="yst-max-w-lg yst-p-0 yst-rounded-3xl yst-introduction-modal-panel">
216
                                                <ModalContent onClose={ setIsModalOpenFalse } focusElementRef={ focusElementRef } />
217
                                        </Modal.Panel>
218
                                </Modal>
219
                        }
220
                </IconAIFixesButton>
221
        );
222
};
223

224
AIOptimizeButton.propTypes = {
2✔
225
        id: PropTypes.string.isRequired,
226
        isPremium: PropTypes.bool,
227
};
228

229
export default AIOptimizeButton;
230

STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc