Skip to content

Commit f99c198

Browse files
authored
Merge pull request #23552 from Yoast/feature/bulk-editor-ph2
Feature/bulk editor ph2
2 parents 2d053c1 + cf63ce7 commit f99c198

168 files changed

Lines changed: 11444 additions & 1255 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.phpcs.xml.dist

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,12 @@
275275
<exclude-pattern>/tests/*/Doubles/*</exclude-pattern>
276276
</rule>
277277

278+
<!-- The double/mock classes mirror the real classes' properties; enforcing type hints on the mock's own
279+
property declarations adds no value, as the real class is the source of truth. -->
280+
<rule ref="SlevomatCodingStandard.TypeHints.PropertyTypeHint">
281+
<exclude-pattern>/tests/*/Doubles/*</exclude-pattern>
282+
</rule>
283+
278284

279285
<!--
280286
#############################################################################

admin/metabox/class-metabox.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -801,7 +801,7 @@ public function save_postdata( $post_id ) {
801801
* @return bool Whether the given meta value key is disabled.
802802
*/
803803
public function is_meta_value_disabled( $key ) {
804-
if ( $key === 'linkdex' && ! $this->seo_analysis->is_enabled() ) {
804+
if ( in_array( $key, [ 'linkdex', 'seo_title_score', 'meta_description_score' ], true ) && ! $this->seo_analysis->is_enabled() ) {
805805
return true;
806806
}
807807

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@
111111
"Yoast\\WP\\SEO\\Composer\\Actions::check_coding_standards"
112112
],
113113
"check-cs-thresholds": [
114-
"@putenv YOASTCS_THRESHOLD_ERRORS=2366",
114+
"@putenv YOASTCS_THRESHOLD_ERRORS=2220",
115115
"@putenv YOASTCS_THRESHOLD_WARNINGS=254",
116116
"Yoast\\WP\\SEO\\Composer\\Actions::check_cs_thresholds"
117117
],

css/src/bulk-editor-page.css

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
@import "@draft-js-plugins/mention/lib/plugin.css";
2+
@import "draft-js/dist/Draft.css";
3+
14
.seo_page_wpseo_page_bulk_edit {
25
@apply yst-bg-slate-100;
36

@@ -84,6 +87,59 @@
8487
overflow-wrap: break-word;
8588
}
8689

90+
/* Shared layout for the filter popover's checkbox groups: each option is a full-width, rounded hover target.
91+
* The popover drops its own horizontal padding (yst-px-0) so the target can span the full width; each option
92+
* carries its own inset instead, which keeps the bleed without a negative margin. */
93+
.yst-root .yst-bulk-editor-filter-group .yst-checkbox {
94+
@apply yst-cursor-pointer yst-rounded yst-px-4 yst-py-2;
95+
}
96+
97+
.yst-root .yst-bulk-editor-filter-group .yst-checkbox:hover {
98+
@apply yst-bg-slate-50;
99+
}
100+
101+
.yst-root .yst-bulk-editor-filter-group label {
102+
@apply yst-cursor-pointer !important;
103+
}
104+
105+
.yst-root .yst-bulk-editor-filter-group .yst-checkbox-group__options {
106+
@apply yst-gap-0;
107+
}
108+
109+
.yst-root .yst-bulk-editor-filter-group .yst-checkbox-group__label {
110+
@apply yst-m-0;
111+
}
112+
113+
/* The "Overview selection" filter group. It is separated from the status filters below it by a divider,
114+
* mirroring the one above the "needs improvement" group. */
115+
.yst-root .yst-bulk-editor-overview-selection {
116+
@apply yst-border-b yst-border-slate-200 yst-mb-0.5 yst-pb-0.5;
117+
}
118+
119+
/* The "needs improvement" filter group. It is separated from the status filters by a divider; each option is
120+
* prefixed with a red score dot; and the group's legend is hidden visually but kept for assistive tech, so the
121+
* dot's "needs improvement" meaning is never conveyed by colour alone. */
122+
.yst-root .yst-bulk-editor-needs-improvement {
123+
@apply yst-border-t yst-border-slate-200 yst-mt-0.5 yst-pt-0.5;
124+
}
125+
126+
.yst-root .yst-bulk-editor-needs-improvement .yst-checkbox-group__label {
127+
@apply yst-sr-only;
128+
}
129+
130+
.yst-root .yst-bulk-editor-needs-improvement .yst-checkbox__label {
131+
@apply yst-flex yst-items-center;
132+
}
133+
134+
.yst-root .yst-bulk-editor-needs-improvement .yst-checkbox__label::before {
135+
content: "";
136+
/* A 12px dot (the design's 11.14px oval on a scaled frame) with an 8px gap to the label. */
137+
@apply yst-me-2 yst-h-3 yst-w-3 yst-flex-shrink-0 yst-rounded-full;
138+
139+
/* The Yoast bad-score red — the same value as .wpseo-score-icon.bad and the ui-library .yst-bad-score. */
140+
background-color: #dc3232;
141+
}
142+
87143
/* The row title links to the post editor. */
88144
.yst-root .yst-bulk-editor-title-link,
89145
.yst-root .yst-bulk-editor-title-link:visited {
@@ -113,6 +169,11 @@
113169
@apply yst-outline-none;
114170
}
115171

172+
.yst-root .yst-bulk-editor-textarea-field textarea {
173+
@apply yst-min-h-16;
174+
field-sizing: content;
175+
}
176+
116177
/* The guided-tour spotlight overlay is decorative only; a separate backdrop blocks interaction, so it ignores pointer events. */
117178
.yst-root .yst-tour-spotlight {
118179
pointer-events: none;
@@ -140,3 +201,45 @@
140201
border-inline-start: 14px solid transparent !important;
141202
border-inline-end: 14px solid white !important;
142203
}
204+
205+
.yst-root .yst-replacevar__editor {
206+
@apply
207+
yst-w-full
208+
yst-min-h-16
209+
yst-py-2
210+
yst-px-3
211+
yst-border
212+
yst-border-slate-300
213+
yst-rounded-md
214+
yst-shadow-sm
215+
yst-bg-white
216+
yst-text-sm
217+
yst-leading-5
218+
yst-text-slate-800
219+
yst-placeholder-slate-500
220+
focus-within:yst-outline-none
221+
focus-within:yst-ring-primary-500
222+
focus-within:yst-border-primary-500
223+
focus-within:yst-ring-2
224+
focus-within:yst-border-opacity-0;
225+
}
226+
227+
.yst-root .yst-replacevar__editor span.yst-replacevar__mention {
228+
@apply yst-text-sm;
229+
}
230+
231+
.yst-root .yst-replacevar {
232+
@apply yst-m-0;
233+
}
234+
235+
.yst-root .yst-replacevar__label {
236+
@apply yst-sr-only;
237+
}
238+
239+
.yst-root .yst-replacevar__buttons {
240+
display: none;
241+
}
242+
243+
.yst-root .DraftEditor-editorContainer {
244+
@apply yst-leading-6;
245+
}

inc/class-wpseo-meta.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,14 @@ class WPSEO_Meta {
132132
'type' => 'hidden',
133133
'default_value' => '0',
134134
],
135+
'seo_title_score' => [
136+
'type' => 'hidden',
137+
'default_value' => '0',
138+
],
139+
'meta_description_score' => [
140+
'type' => 'hidden',
141+
'default_value' => '0',
142+
],
135143
'is_cornerstone' => [
136144
'type' => 'hidden',
137145
'default_value' => 'false',
@@ -443,6 +451,14 @@ public static function sanitize_post_meta( $meta_value, $meta_key ) {
443451
}
444452
break;
445453

454+
case ( in_array( $meta_key, [ self::$meta_prefix . 'seo_title_score', self::$meta_prefix . 'meta_description_score' ], true ) ):
455+
// Per-field scores are 0-100 percentages; out-of-range input keeps the "never scored" default.
456+
$int = WPSEO_Utils::validate_int( $meta_value );
457+
if ( $int !== false && $int >= 0 && $int <= 100 ) {
458+
$clean = (string) $int; // Convert to string to make sure default check works.
459+
}
460+
break;
461+
446462
case ( $field_def['type'] === 'checkbox' ):
447463
// Only allow value if it's one of the predefined options.
448464
if ( in_array( $meta_value, [ 'on', 'off' ], true ) ) {
Lines changed: 3 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,8 @@
1-
import ExclamationIcon from "@heroicons/react/outline/ExclamationIcon";
1+
import { Actions, CloseButton } from "../../../shared-admin/components/danger-modal";
22
import { __ } from "@wordpress/i18n";
3-
import { Button, Modal, useSvgAria } from "@yoast/ui-library";
4-
import { noop } from "lodash";
3+
import { Button } from "@yoast/ui-library";
54
import PropTypes from "prop-types";
65

7-
/**
8-
* Wraps the error copy below the title, applying the muted modal-description tone.
9-
* The message `Paragraph`s add their own top margin, so the first paragraph sits
10-
* just under the title.
11-
*
12-
* @param {JSX.node} children The copy (message `Body`).
13-
* @returns {JSX.Element} The element.
14-
*/
15-
export const ModalDescription = ( { children } ) => (
16-
<div className="yst-mt-1 yst-text-sm yst-text-slate-500">
17-
{ children }
18-
</div>
19-
);
20-
ModalDescription.propTypes = { children: PropTypes.node.isRequired };
21-
22-
/**
23-
* The button row shown at the bottom of an error modal.
24-
*
25-
* @param {JSX.node} children The buttons.
26-
* @returns {JSX.Element} The element.
27-
*/
28-
export const Actions = ( { children } ) => (
29-
<div className="yst-mt-6 yst-flex yst-flex-row yst-justify-end yst-gap-3">
30-
{ children }
31-
</div>
32-
);
33-
Actions.propTypes = { children: PropTypes.node.isRequired };
34-
35-
/**
36-
* A "Close" button that dismisses the modal.
37-
*
38-
* @param {function} onClose Dismisses the modal.
39-
* @returns {JSX.Element} The element.
40-
*/
41-
export const CloseButton = ( { onClose } ) => (
42-
<Button variant="secondary" onClick={ onClose }>
43-
{ __( "Close", "wordpress-seo" ) }
44-
</Button>
45-
);
46-
CloseButton.propTypes = { onClose: PropTypes.func.isRequired };
47-
486
/**
497
* The action row for retryable errors: always a Close button, plus a Try again
508
* when `showActions` is set (a retryable context such as a timeout or a failed
@@ -71,47 +29,4 @@ RetryableActions.propTypes = {
7129
onClose: PropTypes.func.isRequired,
7230
};
7331

74-
/**
75-
* The "Danger modal" shell every AI error shares: a centered modal panel with the
76-
* rose-circle outline exclamation icon, the resolved title, and a dismiss control.
77-
*
78-
* @param {boolean} isOpen Whether the modal is open.
79-
* @param {string} title The modal title.
80-
* @param {function} [onClose=noop] Dismisses the modal.
81-
* @param {JSX.node} children The modal body (copy + actions).
82-
* @returns {JSX.Element} The element.
83-
*/
84-
export const DangerModal = ( { isOpen, title, onClose = noop, children } ) => {
85-
const svgAriaProps = useSvgAria();
86-
87-
return (
88-
<Modal isOpen={ isOpen } onClose={ onClose }>
89-
<Modal.Panel className="yst-max-w-lg" closeButtonScreenReaderText={ __( "Dismiss", "wordpress-seo" ) }>
90-
{ /*
91-
* An unconditional row (icon | title + body), not the `sm:`-gated
92-
* stack-then-row other danger modals use. This modal is portaled inside
93-
* the block editor, where the viewport `sm` breakpoint does not match
94-
* reliably; gating the layout on it leaves the modal stuck stacked and
95-
* centered. Mirrors `ReplaceContentModal`, which renders in the same context.
96-
*/ }
97-
<div className="yst-flex yst-items-start yst-gap-4">
98-
<div className="yst-flex-shrink-0 yst-flex yst-items-center yst-justify-center yst-h-10 yst-w-10 yst-rounded-full yst-bg-red-100">
99-
<ExclamationIcon className="yst-h-6 yst-w-6 yst-text-red-600" { ...svgAriaProps } />
100-
</div>
101-
<div className="yst-text-start yst-flex-1 yst-min-w-0">
102-
<Modal.Title className="yst-text-lg yst-leading-6 yst-font-medium yst-text-slate-900">
103-
{ title }
104-
</Modal.Title>
105-
{ children }
106-
</div>
107-
</div>
108-
</Modal.Panel>
109-
</Modal>
110-
);
111-
};
112-
DangerModal.propTypes = {
113-
isOpen: PropTypes.bool.isRequired,
114-
title: PropTypes.string.isRequired,
115-
onClose: PropTypes.func,
116-
children: PropTypes.node.isRequired,
117-
};
32+
export { DangerModal, CloseButton, Actions, ModalDescription } from "../../../shared-admin/components/danger-modal";

packages/js/src/ai-generator/components/tip-notification.js

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,15 @@ import { __, sprintf } from "@wordpress/i18n";
44
import { Button, Notifications, useToggleState } from "@yoast/ui-library";
55
import { safeCreateInterpolateElement } from "../../helpers/i18n";
66
import { CONTENT_TYPE, EDIT_TYPE, MIN_CHARACTERS_DEFAULT, MIN_CHARACTERS_IRREGULAR, STORE_NAME_EDITOR } from "../constants";
7+
import { getVisibleContentLength } from "../../shared-admin/helpers/get-visible-content-length";
78
import { useTypeContext } from "../hooks";
89

910
const ALERT_KEY = "ai_generator_tip_notification";
1011

1112
/**
1213
* Returns the minimum content length in characters for the given post type and content type.
13-
* @param {string} postType The current post type.
14+
* @param {boolean} isWooProductEntity Whether the current post type is a WooCommerce product.
1415
* @param {string} contentType The current content type.
15-
* @param {boolean} isWooCommerceActive Whether WooCommerce is active.
1616
* @returns {number} The minimum content length.
1717
*/
1818
const getMinimumContentLength = ( isWooProductEntity, contentType ) =>
@@ -55,8 +55,12 @@ export const TipNotification = () => {
5555
const minimumContentLength = useMemo( () => getMinimumContentLength( isWooProductEntity, contentType ),
5656
[ contentType, isWooProductEntity ] );
5757

58+
// Measured on the visible text rather than on the editor markup, so block comments and HTML tags do not count
59+
// towards the threshold. This matches the prompt content the AI receives, which is built from parsed sentences.
60+
const visibleContentLength = useMemo( () => getVisibleContentLength( content ), [ content ] );
61+
5862
// If the tip is dismissed or ignored, or the content length is greater than the minimum required, don't show the tip.
59-
if ( isDismissed || isIgnored || content.length > minimumContentLength ) {
63+
if ( isDismissed || isIgnored || visibleContentLength > minimumContentLength ) {
6064
// Bail to not show the tip notification.
6165
return null;
6266
}

packages/js/src/ai-generator/constants/index.js

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -103,16 +103,10 @@ export const FETCH_RESPONSE_STATUS = {
103103
abort: "abort",
104104
};
105105

106-
/**
107-
* The maximum number of tokens to consider for default content types.
108-
* @type {number}
109-
*/
110-
export const MAX_TOKENS_DEFAULT = 300;
111-
/**
112-
* The maximum number of tokens to consider for irregular content types (e.g. products or terms).
113-
* @type {number}
106+
/*
107+
* The prompt's token budgets (MAX_TOKENS_DEFAULT / MAX_TOKENS_IRREGULAR) live in
108+
* shared-admin/helpers/prompt-content.js, so the in-editor generator and the bulk editor share one definition.
114109
*/
115-
export const MAX_TOKENS_IRREGULAR = 150;
116110

117111
/**
118112
* The expected minimum character length of content for default content types.

0 commit comments

Comments
 (0)