diff --git a/css/src/bulk-editor-page.css b/css/src/bulk-editor-page.css
index f07eb7c288d..b0614ca3ad7 100644
--- a/css/src/bulk-editor-page.css
+++ b/css/src/bulk-editor-page.css
@@ -1,3 +1,6 @@
+@import "@draft-js-plugins/mention/lib/plugin.css";
+@import "draft-js/dist/Draft.css";
+
.seo_page_wpseo_page_bulk_edit {
@apply yst-bg-slate-100;
@@ -167,6 +170,7 @@
}
.yst-root .yst-bulk-editor-textarea-field textarea {
+ @apply yst-min-h-16;
field-sizing: content;
}
@@ -197,3 +201,45 @@
border-inline-start: 14px solid transparent !important;
border-inline-end: 14px solid white !important;
}
+
+.yst-root .yst-replacevar__editor {
+ @apply
+ yst-w-full
+ yst-min-h-16
+ yst-py-2
+ yst-px-3
+ yst-border
+ yst-border-slate-300
+ yst-rounded-md
+ yst-shadow-sm
+ yst-bg-white
+ yst-text-sm
+ yst-leading-5
+ yst-text-slate-800
+ yst-placeholder-slate-500
+ focus-within:yst-outline-none
+ focus-within:yst-ring-primary-500
+ focus-within:yst-border-primary-500
+ focus-within:yst-ring-2
+ focus-within:yst-border-opacity-0;
+}
+
+.yst-root .yst-replacevar__editor span.yst-replacevar__mention {
+ @apply yst-text-sm;
+}
+
+.yst-root .yst-replacevar {
+ @apply yst-m-0;
+}
+
+.yst-root .yst-replacevar__label {
+ @apply yst-sr-only;
+}
+
+.yst-root .yst-replacevar__buttons {
+ display: none;
+}
+
+.yst-root .DraftEditor-editorContainer {
+ @apply yst-leading-6;
+}
\ No newline at end of file
diff --git a/packages/js/src/bulk-editor/components/table/focus-keyphrase-editable-field-cell.js b/packages/js/src/bulk-editor/components/table/focus-keyphrase-field.js
similarity index 85%
rename from packages/js/src/bulk-editor/components/table/focus-keyphrase-editable-field-cell.js
rename to packages/js/src/bulk-editor/components/table/focus-keyphrase-field.js
index 5f1cab77724..75b058d67c8 100644
--- a/packages/js/src/bulk-editor/components/table/focus-keyphrase-editable-field-cell.js
+++ b/packages/js/src/bulk-editor/components/table/focus-keyphrase-field.js
@@ -1,6 +1,5 @@
import { __ } from "@wordpress/i18n";
import { TextareaField } from "@yoast/ui-library";
-import { EditableFieldCell } from "./table-cells";
/**
* Focus keyphrase editable field cell.
@@ -10,7 +9,7 @@ import { EditableFieldCell } from "./table-cells";
*
* @returns {JSX.Element} The cell.
*/
-export const FocusKeyphraseEditableFieldCell = ( { value, ...props } ) => {
+export const FocusKeyphraseField = ( { value, ...props } ) => {
const warnings = [
( /<[^>]*>/u ).test( value ) && __( "Your keyphrase contains HTML tags that will be stripped on save.", "wordpress-seo" ),
value.length === 191 && __( "You reached the maximum limit of 191 characters.", "wordpress-seo" ),
@@ -27,12 +26,13 @@ export const FocusKeyphraseEditableFieldCell = ( { value, ...props } ) => {
: null;
return (
-
);
};
diff --git a/packages/js/src/bulk-editor/components/table/preview-editable-field-cell.js b/packages/js/src/bulk-editor/components/table/preview-editable-field-cell.js
new file mode 100644
index 00000000000..c74e1062cb6
--- /dev/null
+++ b/packages/js/src/bulk-editor/components/table/preview-editable-field-cell.js
@@ -0,0 +1,44 @@
+import { Table } from "@yoast/ui-library";
+import { ReplacementVariableEditorStandalone } from "@yoast/replacement-variable-editor";
+import { noop } from "lodash";
+import { sprintf, __ } from "@wordpress/i18n";
+import { FOCUS_KEYPHRASE_KEY } from "../../constants";
+
+/**
+ *
+ * @param {object} props The component props.
+ * @param {object} props.field The field to render.
+ * @param {object} props.item The content item to render.
+ * @param {Array} props.replacementVariables The replacement variables for this content type.
+ * @param {Array} props.recommendedReplacementVariables The recommended replacement variables for this content type.
+ * @returns {JSX.Element} The cell.
+ */
+export const PreviewEditableFieldCell = ( { field, item, replacementVariables, recommendedReplacementVariables } ) => {
+ if ( field.key === FOCUS_KEYPHRASE_KEY ) {
+ return (
+
+ { item[ field.key ] }
+
+ );
+ }
+
+ return (
+
+
+ { sprintf(
+ /* translators: %1$s expands to the field label, %2$s to the content item title. */
+ __( "%1$s for %2$s", "wordpress-seo" ), field.label, item.title ) }
+
+
+
+ );
+};
diff --git a/packages/js/src/bulk-editor/components/table/table-cells.js b/packages/js/src/bulk-editor/components/table/table-cells.js
index 9c25dbfd8a8..a01e2a585a0 100644
--- a/packages/js/src/bulk-editor/components/table/table-cells.js
+++ b/packages/js/src/bulk-editor/components/table/table-cells.js
@@ -1,10 +1,12 @@
import { Slot } from "@wordpress/components";
-import { useCallback, useEffect, useState } from "@wordpress/element";
+import { useCallback, useEffect, useMemo, useState } from "@wordpress/element";
import { __, sprintf } from "@wordpress/i18n";
-import { Table, Textarea } from "@yoast/ui-library";
-import { TABLE_ROW_INDICATOR_SLOT } from "../../constants";
+import { ReplacementVariableEditor } from "@yoast/replacement-variable-editor";
+import { Table } from "@yoast/ui-library";
+import { TABLE_ROW_INDICATOR_SLOT, FOCUS_KEYPHRASE_KEY } from "../../constants";
import { getStatusLabel } from "./table-helpers";
import AnimateHeight from "react-animate-height";
+import { FocusKeyphraseField } from "./focus-keyphrase-field";
/**
* The title cell (the row header).
@@ -49,51 +51,84 @@ export const TitleCell = ( { item, fieldSetId } ) => {
};
/**
- * An open field cell: an editable textarea. The row's Save and Cancel actions save or
- * discard all of the row's open fields at once.
+ * An open field cell: an editable replacement-variable editor or textarea. The row's Save and
+ * Cancel actions save or discard all of the row's open fields at once.
*
- * @param {Object} props The props.
- * @param {FieldSetField} props.field The field this cell edits.
- * @param {number} props.itemId The item id, to keep the input id unique across rows.
- * @param {string} props.itemTitle The item title, for the accessible name.
- * @param {string} props.value The current draft value.
- * @param {boolean} props.isSaving Whether the row is being saved (disables the input).
- * @param {Function} props.onChange Called with { key, value } when the value changes.
+ * @param {Object} props The props.
+ * @param {FieldSetField} props.field The field this cell edits.
+ * @param {number} props.itemId The item id, to keep the input id unique across rows.
+ * @param {string} props.itemTitle The item title, for the accessible name.
+ * @param {string} props.value The current draft value.
+ * @param {boolean} props.isSaving Whether the row is being saved (disables the input).
+ * @param {Function} props.onChange Called with { key, value } when the value changes.
+ * @param {Array} props.replacementVariables The replacement variables available for this content type.
+ * @param {Array} props.recommendedReplacementVariables The recommended replacement variables for this content type.
* @param {string} props.fieldSetId The active field set's id, scopes the input id across tabs.
*
* @returns {JSX.Element} The cell.
*/
export const EditableFieldCell = ( {
- as: Component = Textarea,
field,
itemId,
itemTitle,
value,
isSaving,
onChange,
+ replacementVariables,
+ recommendedReplacementVariables,
fieldSetId,
- ...props } ) => {
- const handleChange = useCallback( ( event ) => onChange( { key: field.key, value: event.target.value } ), [ onChange, field.key ] );
-
+} ) => {
// Row expand/collapse animation helper.
const [ height, setHeight ] = useState( 0 );
useEffect( () => setHeight( "auto" ), [] );
- return (
-
-
-
-
-
- );
+ // Hooks must be called unconditionally; each handler is used by its respective branch below.
+ const handleReplaceVarChange = useCallback( ( newValue ) => onChange( { key: field.key, value: newValue } ), [ onChange, field.key ] );
+ const handleTextareaChange = useCallback( ( event ) => onChange( { key: field.key, value: event.target.value } ), [ onChange, field.key ] );
+
+ /*
+ * Preemptively add a trailing space when the value ends with a complete %%var%% token.
+ * The replacement variable editor auto-adds a space after inserting a variable; if our
+ * initial content already ends with one and we don't mirror that, the editor sees a
+ * mismatch on first render and triggers a spurious onChange → save prompt.
+ */
+ const editorContent = useMemo( () => ( value?.match( /%%\w+%%$/ ) ? `${ value } ` : value ) || "", [ value ] );
+
+ const id = `bulk-editor-edit-${ itemId }-${ fieldSetId }-${ field.key }`;
+ const label = sprintf(
+ /* translators: %1$s expands to the field label, %2$s to the content item title. */
+ __( "%1$s for %2$s", "wordpress-seo" ), field.label, itemTitle );
+
+ if ( field.key === FOCUS_KEYPHRASE_KEY ) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
};
diff --git a/packages/js/src/bulk-editor/components/table/table-row.js b/packages/js/src/bulk-editor/components/table/table-row.js
index 54cddf40fa1..bea01a1b1ed 100644
--- a/packages/js/src/bulk-editor/components/table/table-row.js
+++ b/packages/js/src/bulk-editor/components/table/table-row.js
@@ -1,11 +1,12 @@
import { Slot, __experimentalUseSlotFills as useSlotFills } from "@wordpress/components";
import { Fragment, useCallback } from "@wordpress/element";
+import { useSelect } from "@wordpress/data";
import { __, sprintf } from "@wordpress/i18n";
import { Button, Checkbox, Table } from "@yoast/ui-library";
-import { TABLE_CELL_FIELD_SLOT, FOCUS_KEYPHRASE_KEY } from "../../constants";
+import { STORE_NAME, TABLE_CELL_FIELD_SLOT } from "../../constants";
import { EditableFieldCell, TitleCell } from "./table-cells";
-import { FocusKeyphraseEditableFieldCell } from "./focus-keyphrase-editable-field-cell";
import { getRowEditState, isRowEditDisabled } from "./table-helpers";
+import { PreviewEditableFieldCell } from "./preview-editable-field-cell";
/**
* A content row. Each field-set cell renders as plain text, or — when the row is in edit mode and the field is
@@ -39,6 +40,13 @@ export const BulkEditorRow = ( {
} ) => {
const { isEditing, openFields, draft, savingFields } = getRowEditState( edit );
const { onStartEdit, onChangeField, onApplyField, onApplyRow, onCancelEdit, onDiscardField, onFieldApplied, isApplyingAll } = editing;
+ const { replacementVariables, recommendedReplacementVariables } = useSelect( ( select ) => {
+ const activeContentType = select( STORE_NAME ).selectActiveContentTypeName();
+ return {
+ replacementVariables: select( STORE_NAME ).selectReplacementVariablesFor( activeContentType, "custom_post_type" ),
+ recommendedReplacementVariables: select( STORE_NAME ).selectRecommendedReplacementVariablesFor( activeContentType, "custom_post_type" ),
+ };
+ }, [] );
// Treat a batch "Save edits" as saving this row too, so its inputs and Save/Cancel lock and a per-field save can't race the batch.
const isSaving = Object.keys( savingFields ).length > 0 || isApplyingAll;
const fillsSeoTitles = useSlotFills( `${ TABLE_CELL_FIELD_SLOT }/seoTitle/${item.id}` );
@@ -104,25 +112,15 @@ export const BulkEditorRow = ( {
if ( ! openFields.includes( field.key ) ) {
return (
-
- { item[ field.key ] }
-
+
);
}
- if ( field.key === FOCUS_KEYPHRASE_KEY ) {
- // The focus keyphrase has more warnings.
- return ;
- }
-
return ;
} }
diff --git a/packages/js/src/bulk-editor/field-sets.js b/packages/js/src/bulk-editor/field-sets.js
index f90d0420b17..4ca1c8acabc 100644
--- a/packages/js/src/bulk-editor/field-sets.js
+++ b/packages/js/src/bulk-editor/field-sets.js
@@ -25,12 +25,13 @@ import { FIELD_SET_SEARCH, FIELD_SET_SOCIAL, FOCUS_KEYPHRASE_KEY } from "./const
* One editable column within a field set.
*
* @typedef {Object} FieldSetField
- * @property {string} key The {@link BulkEditorItem} property this column edits.
- * @property {string} label The column header label.
- * @property {string} param The request parameter name the save endpoint expects for this field.
- * @property {string} width The column width.
- * @property {string} [endpoint] A data-provider endpoint key that saves this field, overriding the field set's
- * default endpoint.
+ * @property {string} key The {@link BulkEditorItem} property this column edits.
+ * @property {string} label The column header label.
+ * @property {string} param The request parameter name the save endpoint expects for this field.
+ * @property {string} width The column width.
+ * @property {string} [type] "title" or "description" for replacement-variable fields; absent for plain text fields.
+ * @property {string} [endpoint] A data-provider endpoint key that saves this field, overriding the field set's
+ * default endpoint.
*/
/**
@@ -65,8 +66,8 @@ export const getFieldSets = () => {
endpoint: "update_search",
fields: [
focusKeyphrase,
- { key: "seoTitle", label: __( "SEO title", "wordpress-seo" ), param: "seo_title", width: "sm:yst-w-[19%]" },
- { key: "metaDescription", label: __( "Meta description", "wordpress-seo" ), param: "meta_description", width: "sm:yst-w-[33%]" },
+ { key: "seoTitle", label: __( "SEO title", "wordpress-seo" ), param: "seo_title", width: "sm:yst-w-[19%]", type: "title" },
+ { key: "metaDescription", label: __( "Meta description", "wordpress-seo" ), param: "meta_description", width: "sm:yst-w-[33%]", type: "description" },
],
},
[ FIELD_SET_SOCIAL ]: {
@@ -75,8 +76,8 @@ export const getFieldSets = () => {
endpoint: "update_social",
fields: [
focusKeyphrase,
- { key: "socialTitle", label: __( "Social title", "wordpress-seo" ), param: "social_title", width: "sm:yst-w-[19%]" },
- { key: "socialDescription", label: __( "Social description", "wordpress-seo" ), param: "social_description", width: "sm:yst-w-[33%]" },
+ { key: "socialTitle", label: __( "Social title", "wordpress-seo" ), param: "social_title", width: "sm:yst-w-[19%]", type: "title" },
+ { key: "socialDescription", label: __( "Social description", "wordpress-seo" ), param: "social_description", width: "sm:yst-w-[33%]", type: "description" },
],
},
};
diff --git a/packages/js/src/bulk-editor/hooks/use-inline-edit.js b/packages/js/src/bulk-editor/hooks/use-inline-edit.js
index 912bc5b1c69..73eae4176ce 100644
--- a/packages/js/src/bulk-editor/hooks/use-inline-edit.js
+++ b/packages/js/src/bulk-editor/hooks/use-inline-edit.js
@@ -41,6 +41,20 @@ const resolveItemValue = ( key, draftValue, sanitized ) => {
*/
const getFirstSanitized = ( response ) => response?.results?.[ 0 ]?.sanitized;
+/**
+ * Returns the draft value to persist, stripping it back to empty when it still equals the item's
+ * fallback template. This prevents clicking Save on an unedited row from baking the fallback template
+ * in as an explicit stored value, which would disconnect the post from Search Appearance.
+ *
+ * @param {string} value The current draft value.
+ * @param {Object|undefined} item The source item (may be undefined if the row was not found).
+ * @param {string} fieldKey The JS camelCase field key (e.g. "seoTitle").
+ *
+ * @returns {string} The value to send to the server.
+ */
+const normalizeDraftValue = ( value, item, fieldKey ) =>
+ value === ( item?.[ `${ fieldKey }Fallback` ] ?? "" ) ? "" : value;
+
/**
* Re-scores a saved row from an update result, when it carries rendered search fields.
*
@@ -187,7 +201,7 @@ export const useInlineEdit = ( { dataProvider, remoteDataProvider, fieldSets, ac
return;
}
const draftValues = Object.fromEntries(
- fieldSets[ activeFieldSet ].fields.map( ( field ) => [ field.key, item[ field.key ] ?? "" ] )
+ fieldSets[ activeFieldSet ].fields.map( ( field ) => [ field.key, item[ field.key ] || item[ `${ field.key }Fallback` ] || "" ] )
);
startEdit( { id, draft: draftValues } );
}, [ items, fieldSets, activeFieldSet, startEdit ] );
@@ -206,7 +220,8 @@ export const useInlineEdit = ( { dataProvider, remoteDataProvider, fieldSets, ac
return;
}
- const value = rowEdit.draft[ key ];
+ const rowItem = items.find( ( candidate ) => candidate.id === id );
+ const value = normalizeDraftValue( rowEdit.draft[ key ], rowItem, key );
setSavingField( { id, key, isSaving: true } );
try {
const response = await remoteDataProvider.fetchJson( endpoint, {}, {
@@ -220,7 +235,7 @@ export const useInlineEdit = ( { dataProvider, remoteDataProvider, fieldSets, ac
setSavingField( { id, key, isSaving: false } );
setHasSaveError( true );
}
- }, [ fieldSets, activeFieldSet, dataProvider, remoteDataProvider, editingRows, setSavingField, closeField, updateItem, scoreFields ] );
+ }, [ fieldSets, activeFieldSet, dataProvider, remoteDataProvider, editingRows, items, setSavingField, closeField, updateItem, scoreFields ] );
// Saves all open fields of a single row in as few requests as possible — one POST per endpoint, all fields
// merged into one item. Called by the per-row Save button; re-scores once all succeed.
@@ -232,6 +247,7 @@ export const useInlineEdit = ( { dataProvider, remoteDataProvider, fieldSets, ac
}
// Group the row's open fields by endpoint — one item per endpoint, all fields merged in.
+ const rowItem = items.find( ( candidate ) => candidate.id === id );
const batches = {};
rowEdit.openFields.forEach( ( key ) => {
const field = fieldSet.fields.find( ( candidate ) => candidate.key === key );
@@ -246,8 +262,9 @@ export const useInlineEdit = ( { dataProvider, remoteDataProvider, fieldSets, ac
if ( ! batches[ endpointKey ] ) {
batches[ endpointKey ] = { endpoint, item: { id }, applied: [] };
}
- batches[ endpointKey ].item[ field.param ] = rowEdit.draft[ key ];
- batches[ endpointKey ].applied.push( { key, value: rowEdit.draft[ key ] } );
+ const value = normalizeDraftValue( rowEdit.draft[ key ], rowItem, key );
+ batches[ endpointKey ].item[ field.param ] = value;
+ batches[ endpointKey ].applied.push( { key, value } );
} );
const groups = Object.values( batches );
@@ -283,7 +300,7 @@ export const useInlineEdit = ( { dataProvider, remoteDataProvider, fieldSets, ac
if ( hasFailure ) {
setHasSaveError( true );
}
- }, [ fieldSets, activeFieldSet, dataProvider, remoteDataProvider, editingRows, setSavingField, updateItem, closeField, scoreFields ] );
+ }, [ fieldSets, activeFieldSet, dataProvider, remoteDataProvider, editingRows, items, setSavingField, updateItem, closeField, scoreFields ] );
// Saves every open edit as one batch. Returns true (clean), false (a request failed), or null (a save was
// already in flight), so the tab-switch modal only closes on a real failure and not on a re-entrant call.
@@ -317,8 +334,10 @@ export const useInlineEdit = ( { dataProvider, remoteDataProvider, fieldSets, ac
if ( ! batches[ endpointKey ].rows[ id ] ) {
batches[ endpointKey ].rows[ id ] = { item: { id }, applied: [] };
}
- batches[ endpointKey ].rows[ id ].item[ field.param ] = row.draft[ key ];
- batches[ endpointKey ].rows[ id ].applied.push( { id, key, value: row.draft[ key ] } );
+ const rowItem = items.find( ( candidate ) => candidate.id === id );
+ const value = normalizeDraftValue( row.draft[ key ], rowItem, key );
+ batches[ endpointKey ].rows[ id ].item[ field.param ] = value;
+ batches[ endpointKey ].rows[ id ].applied.push( { id, key, value } );
} );
} );
@@ -374,7 +393,7 @@ export const useInlineEdit = ( { dataProvider, remoteDataProvider, fieldSets, ac
isApplyingAllRef.current = false;
setIsApplyingAll( false );
}
- }, [ fieldSets, activeFieldSet, dataProvider, remoteDataProvider, editingRows, updateItem, closeField, scoreFields ] );
+ }, [ fieldSets, activeFieldSet, dataProvider, remoteDataProvider, editingRows, items, updateItem, closeField, scoreFields ] );
const editing = useMemo( () => ( {
editingRows,
diff --git a/packages/js/src/bulk-editor/hooks/use-posts.js b/packages/js/src/bulk-editor/hooks/use-posts.js
index 803f3616623..69148c6c7b4 100644
--- a/packages/js/src/bulk-editor/hooks/use-posts.js
+++ b/packages/js/src/bulk-editor/hooks/use-posts.js
@@ -19,6 +19,10 @@ const formatPost = ( post ) => ( {
metaDescription: post.meta_description,
socialTitle: post.social_title,
socialDescription: post.social_description,
+ seoTitleFallback: post.seo_title_fallback ?? "",
+ metaDescriptionFallback: post.meta_description_fallback ?? "",
+ socialTitleFallback: post.social_title_fallback ?? "",
+ socialDescriptionFallback: post.social_description_fallback ?? "",
editable: post.editable,
needsImprovement: post.needs_improvement ?? {},
} );
diff --git a/packages/js/src/bulk-editor/initialize.js b/packages/js/src/bulk-editor/initialize.js
index 41b46f49800..b7f8ab0a02a 100644
--- a/packages/js/src/bulk-editor/initialize.js
+++ b/packages/js/src/bulk-editor/initialize.js
@@ -8,7 +8,13 @@ import { get } from "lodash";
import { createHashRouter, createRoutesFromElements, Route, RouterProvider } from "react-router-dom";
import { GenericAlert } from "../ai-generator/components/errors";
import { fixWordPressMenuScrolling, MAX_TOKENS_DEFAULT, MAX_TOKENS_IRREGULAR } from "../shared-admin/helpers";
-import { getMyyoastConnectionState, LINK_PARAMS_NAME, MYYOAST_CONNECTION_NAME, OPT_IN_NOTIFICATION_NAME } from "../shared-admin/store";
+import {
+ getMyyoastConnectionState,
+ LINK_PARAMS_NAME,
+ MYYOAST_CONNECTION_NAME,
+ OPT_IN_NOTIFICATION_NAME,
+ REPLACEMENT_VARIABLES_NAME,
+ getReplacementVariablesInitialState } from "../shared-admin/store";
// Imported directly rather than through the barrel: it pulls in `yoastseo`, which must not become a
// dependency of the other pages that import that barrel.
import { getVisibleContentLength } from "../shared-admin/helpers/get-visible-content-length";
@@ -57,7 +63,6 @@ export const getPreselectionState = ( initialSelection = {} ) => {
.slice( 0, BULK_UPDATE_BATCH_SIZE );
return {
- // An empty or unknown name resolves to the first available content type in the app.
activeContentType: typeof initialSelection.contentType === "string" ? initialSelection.contentType : "",
selection: {
selectedIds,
@@ -77,12 +82,18 @@ domReady( () => {
}
// Null when the MyYoast connection feature is unavailable (flag off / not provisioned).
const myyoastConnection = get( window, "wpseoBulkEditorData.myyoastConnection", null );
-
+ const replacementVariables = get( window, "wpseoBulkEditorData.replacementVariables", {} );
+ const contentTypes = get( window, "wpseoBulkEditorData.contentTypes", [] );
+ const preselectionState = getPreselectionState( get( window, "wpseoBulkEditorData.initialSelection", {} ) );
registerStore( {
initialState: {
[ LINK_PARAMS_NAME ]: get( window, "wpseoBulkEditorData.linkParams", {} ),
[ MYYOAST_CONNECTION_NAME ]: getMyyoastConnectionState( myyoastConnection ),
- ...getPreselectionState( get( window, "wpseoBulkEditorData.initialSelection", {} ) ),
+ [ REPLACEMENT_VARIABLES_NAME ]: getReplacementVariablesInitialState( replacementVariables ),
+ ...preselectionState,
+ // Resolve "" (the "first available" sentinel) to the actual first content type name so that store
+ // selectors using it as a lookup key (e.g. replacement variables) work correctly on initial load.
+ activeContentType: preselectionState.activeContentType || contentTypes[ 0 ]?.name || "",
[ OPT_IN_NOTIFICATION_NAME ]: {
seen: get( window, "wpseoBulkEditorData.optInNotificationSeen", {} ),
},
diff --git a/packages/js/src/bulk-editor/store/index.js b/packages/js/src/bulk-editor/store/index.js
index fd87c2296b1..70aedfa7ceb 100644
--- a/packages/js/src/bulk-editor/store/index.js
+++ b/packages/js/src/bulk-editor/store/index.js
@@ -17,6 +17,9 @@ import {
myyoastConnectionActions,
myyoastConnectionReducer,
myyoastConnectionSelectors,
+ replacementVariablesActions,
+ replacementVariablesReducer,
+ replacementVariablesSelectors,
} from "../../shared-admin/store";
import { STORE_NAME } from "../constants";
import activeContentType, { activeContentTypeActions, activeContentTypeSelectors, createInitialActiveContentTypeState } from "./active-content-type";
@@ -48,6 +51,7 @@ const createStore = ( { initialState } ) => {
actions: {
...linkParamsActions,
...preferencesActions,
+ ...replacementVariablesActions,
...activeFieldSetActions,
...activeContentTypeActions,
...queryActions,
@@ -62,6 +66,7 @@ const createStore = ( { initialState } ) => {
selectors: {
...linkParamsSelectors,
...preferencesSelectors,
+ ...replacementVariablesSelectors,
...activeFieldSetSelectors,
...activeContentTypeSelectors,
...querySelectors,
@@ -78,6 +83,7 @@ const createStore = ( { initialState } ) => {
{
[ LINK_PARAMS_NAME ]: getInitialLinkParamsState(),
preferences: createInitialPreferencesState(),
+
activeFieldSet: createInitialActiveFieldSetState(),
activeContentType: createInitialActiveContentTypeState(),
query: createInitialQueryState(),
@@ -94,6 +100,7 @@ const createStore = ( { initialState } ) => {
reducer: combineReducers( {
[ LINK_PARAMS_NAME ]: linkParamsReducer,
preferences,
+ replacementVariables: replacementVariablesReducer,
activeFieldSet,
activeContentType,
query,
diff --git a/packages/js/src/settings/initialize.js b/packages/js/src/settings/initialize.js
index 17e566190da..1fa33943126 100644
--- a/packages/js/src/settings/initialize.js
+++ b/packages/js/src/settings/initialize.js
@@ -8,7 +8,7 @@ import { chunk, filter, forEach, get, includes, reduce } from "lodash";
import { HashRouter } from "react-router-dom";
import { StyleSheetManager } from "styled-components";
import { fixWordPressMenuScrolling } from "../shared-admin/helpers";
-import { LINK_PARAMS_NAME } from "../shared-admin/store";
+import { LINK_PARAMS_NAME, REPLACEMENT_VARIABLES_NAME, getReplacementVariablesInitialState } from "../shared-admin/store";
import App from "./app";
import { STORE_NAME } from "./constants";
import { createValidationSchema, handleSubmit } from "./helpers";
@@ -114,6 +114,7 @@ domReady( () => {
description: __( "Please see the “New” badges and review the Search appearance settings.", "wordpress-seo" ),
} } : {};
+ const replacementVariables = get( window, "wpseoScriptData.replacementVariables", {} );
registerStore( {
initialState: {
@@ -122,6 +123,7 @@ domReady( () => {
currentPromotions: { promotions: get( window, "wpseoScriptData.currentPromotions", [] ) },
llmsTxt: get( window, "wpseoScriptData.llmsTxt", {} ),
schemaFramework: get( window, "wpseoScriptData.schemaFrameworkConfiguration", {} ),
+ [ REPLACEMENT_VARIABLES_NAME ]: getReplacementVariablesInitialState( replacementVariables ),
},
} );
diff --git a/packages/js/src/settings/store/index.js b/packages/js/src/settings/store/index.js
index 07a0b9b560e..a733db1262f 100644
--- a/packages/js/src/settings/store/index.js
+++ b/packages/js/src/settings/store/index.js
@@ -7,6 +7,7 @@ import {
documentTitleSelectors,
getInitialLinkParamsState,
getInitialNotificationsState,
+ getReplacementVariablesInitialState,
LINK_PARAMS_NAME,
linkParamsActions,
linkParamsReducer,
@@ -15,6 +16,10 @@ import {
notificationsActions,
notificationsReducer,
notificationsSelectors,
+ REPLACEMENT_VARIABLES_NAME,
+ replacementVariablesActions,
+ replacementVariablesReducer,
+ replacementVariablesSelectors,
} from "../../shared-admin/store";
import { STORE_NAME } from "../constants";
import { breadcrumbsSelectors } from "./breadcrumbs";
@@ -47,11 +52,6 @@ import media, { createInitialMediaState, mediaActions, mediaControls, mediaSelec
import pageReducer, { getPageInitialState, PAGE_NAME, pageActions, pageControls, pageSelectors } from "./pages";
import postTypes, { createInitialPostTypesState, postTypeControls, postTypesActions, postTypesSelectors } from "./post-types";
import preferences, { createInitialPreferencesState, preferencesActions, preferencesSelectors } from "./preferences";
-import replacementVariables, {
- createInitialReplacementVariablesState,
- replacementVariablesActions,
- replacementVariablesSelectors,
-} from "./replacement-variables";
import schema, { createInitialSchemaState, schemaActions, schemaSelectors } from "./schema";
import search, { createInitialSearchState, searchActions, searchSelectors } from "./search";
import taxonomies, { createInitialTaxonomiesState, taxonomiesActions, taxonomiesSelectors, taxonomyControls } from "./taxonomies";
@@ -130,7 +130,7 @@ const createStore = ( { initialState } ) => {
[ PAGE_NAME ]: getPageInitialState(),
postTypes: createInitialPostTypesState(),
preferences: createInitialPreferencesState(),
- replacementVariables: createInitialReplacementVariablesState(),
+ [ REPLACEMENT_VARIABLES_NAME ]: getReplacementVariablesInitialState(),
schema: createInitialSchemaState(),
[ SCHEMA_FRAMEWORK_NAME ]: createInitialSchemaFrameworkState(),
search: createInitialSearchState(),
@@ -153,7 +153,7 @@ const createStore = ( { initialState } ) => {
[ PAGE_NAME ]: pageReducer,
postTypes,
preferences,
- replacementVariables,
+ [ REPLACEMENT_VARIABLES_NAME ]: replacementVariablesReducer,
schema,
schemaFramework,
search,
diff --git a/packages/js/src/settings/store/replacement-variables.js b/packages/js/src/settings/store/replacement-variables.js
deleted file mode 100644
index bf221651204..00000000000
--- a/packages/js/src/settings/store/replacement-variables.js
+++ /dev/null
@@ -1,55 +0,0 @@
-import { createSelector, createSlice } from "@reduxjs/toolkit";
-import { filter, get, includes } from "lodash";
-
-/**
- * @returns {Object} The initial state.
- */
-export const createInitialReplacementVariablesState = () => ( {
- recommended: get( window, "wpseoScriptData.replacementVariables.recommended", {} ),
- shared: get( window, "wpseoScriptData.replacementVariables.shared", [] ),
- specific: get( window, "wpseoScriptData.replacementVariables.specific", {} ),
- variables: get( window, "wpseoScriptData.replacementVariables.variables", [] ),
-} );
-
-const slice = createSlice( {
- name: "replacementVariables",
- initialState: createInitialReplacementVariablesState(),
- reducers: {},
-} );
-
-const replacementVariablesSelectors = {
- selectRecommendedReplacementVariables: state => get( state, "replacementVariables.recommended", {} ),
- selectSharedReplacementVariables: state => get( state, "replacementVariables.shared", [] ),
- selectSpecificReplacementVariables: state => get( state, "replacementVariables.specific", {} ),
- selectReplacementVariables: state => get( state, "replacementVariables.variables", [] ),
-};
-replacementVariablesSelectors.selectSpecificReplacementVariablesFor = createSelector(
- [
- replacementVariablesSelectors.selectSharedReplacementVariables,
- replacementVariablesSelectors.selectSpecificReplacementVariables,
- ( state, context ) => context,
- ( state, context, fallback ) => fallback,
- ],
- ( shared, specific, context, fallback ) => [ ...shared, ...get( specific, context, get( specific, fallback, [] ) ) ]
-);
-replacementVariablesSelectors.selectReplacementVariablesFor = createSelector(
- [
- replacementVariablesSelectors.selectReplacementVariables,
- replacementVariablesSelectors.selectSpecificReplacementVariablesFor,
- ],
- ( variables, specific ) => filter( variables, ( { name } ) => includes( specific, name ) )
-);
-replacementVariablesSelectors.selectRecommendedReplacementVariablesFor = createSelector(
- [
- replacementVariablesSelectors.selectRecommendedReplacementVariables,
- ( state, context ) => context,
- ( state, context, fallback ) => fallback,
- ],
- ( recommended, context, fallback ) => get( recommended, context, get( recommended, fallback, [] ) )
-);
-
-export { replacementVariablesSelectors };
-
-export const replacementVariablesActions = slice.actions;
-
-export default slice.reducer;
diff --git a/packages/js/src/shared-admin/store/index.js b/packages/js/src/shared-admin/store/index.js
index 514a798bf84..82160f2f400 100644
--- a/packages/js/src/shared-admin/store/index.js
+++ b/packages/js/src/shared-admin/store/index.js
@@ -1,4 +1,5 @@
export * from "./admin-url";
+export * from "./replacement-variables";
export * from "./ai-generator-has-consent";
export * from "./link-params";
export * from "./myyoast-connection";
diff --git a/packages/js/src/shared-admin/store/replacement-variables.js b/packages/js/src/shared-admin/store/replacement-variables.js
new file mode 100644
index 00000000000..b1ccd4365a4
--- /dev/null
+++ b/packages/js/src/shared-admin/store/replacement-variables.js
@@ -0,0 +1,69 @@
+import { createSelector, createSlice } from "@reduxjs/toolkit";
+import { filter, get, includes } from "lodash";
+
+export const REPLACEMENT_VARIABLES_NAME = "replacementVariables";
+
+/**
+ * Maps the raw replacementVariables window payload to store initial state.
+ *
+ * @param {Object} payload The replacementVariables value from the window data object.
+ * @returns {Object} The initial state shape for the replacementVariables slice.
+ */
+export const getReplacementVariablesInitialState = ( payload ) => ( {
+ recommended: get( payload, "recommended", {} ),
+ shared: get( payload, "shared", [] ),
+ specific: get( payload, "specific", {} ),
+ variables: get( payload, "variables", [] ),
+} );
+
+const slice = createSlice( {
+ name: REPLACEMENT_VARIABLES_NAME,
+ initialState: getReplacementVariablesInitialState( {} ),
+ reducers: {},
+} );
+
+const selectRecommendedReplacementVariables = state => get( state, [ REPLACEMENT_VARIABLES_NAME, "recommended" ], {} );
+const selectSharedReplacementVariables = state => get( state, [ REPLACEMENT_VARIABLES_NAME, "shared" ], [] );
+const selectSpecificReplacementVariables = state => get( state, [ REPLACEMENT_VARIABLES_NAME, "specific" ], {} );
+const selectReplacementVariables = state => get( state, [ REPLACEMENT_VARIABLES_NAME, "variables" ], [] );
+
+const selectSpecificReplacementVariablesFor = createSelector(
+ [
+ selectSharedReplacementVariables,
+ selectSpecificReplacementVariables,
+ ( _state, context ) => context,
+ ( _state, _context, fallback ) => fallback,
+ ],
+ ( shared, specific, context, fallback ) => [ ...shared, ...get( specific, context, get( specific, fallback, [] ) ) ]
+);
+
+const selectReplacementVariablesFor = createSelector(
+ [
+ selectReplacementVariables,
+ selectSpecificReplacementVariablesFor,
+ ],
+ ( variables, specific ) => filter( variables, ( { name } ) => includes( specific, name ) )
+);
+
+const selectRecommendedReplacementVariablesFor = createSelector(
+ [
+ selectRecommendedReplacementVariables,
+ ( _state, context ) => context,
+ ( _state, _context, fallback ) => fallback,
+ ],
+ ( recommended, context, fallback ) => get( recommended, context, get( recommended, fallback, [] ) )
+);
+
+export const replacementVariablesSelectors = {
+ selectRecommendedReplacementVariables,
+ selectSharedReplacementVariables,
+ selectSpecificReplacementVariables,
+ selectReplacementVariables,
+ selectSpecificReplacementVariablesFor,
+ selectReplacementVariablesFor,
+ selectRecommendedReplacementVariablesFor,
+};
+
+export const replacementVariablesActions = slice.actions;
+
+export const replacementVariablesReducer = slice.reducer;
diff --git a/packages/js/tests/bulk-editor/app.test.js b/packages/js/tests/bulk-editor/app.test.js
index 3f6249c56cc..dd2465caeeb 100644
--- a/packages/js/tests/bulk-editor/app.test.js
+++ b/packages/js/tests/bulk-editor/app.test.js
@@ -115,7 +115,7 @@ describe( "App", () => {
// Opens an edit on the Search tab, then clicks the Social tab to trigger the guard.
const openEditAndSwitch = async() => {
fireEvent.click( await screen.findByRole( "button", { name: `Edit ${ rowTitle }` } ) );
- expect( screen.getByRole( "textbox", { name: `SEO title for ${ rowTitle }` } ) ).toBeInTheDocument();
+ expect( screen.getByRole( "combobox", { name: `SEO title for ${ rowTitle }` } ) ).toBeInTheDocument();
fireEvent.click( screen.getByRole( "tab", { name: "Social appearance" } ) );
};
@@ -139,20 +139,26 @@ describe( "App", () => {
expect( screen.queryByText( "Unsaved changes" ) ).not.toBeInTheDocument();
expect( screen.getByRole( "tab", { name: "Search appearance" } ) ).toHaveAttribute( "aria-selected", "true" );
// The edit is preserved.
- expect( screen.getByRole( "textbox", { name: `SEO title for ${ rowTitle }` } ) ).toBeInTheDocument();
+ expect( screen.getByRole( "combobox", { name: `SEO title for ${ rowTitle }` } ) ).toBeInTheDocument();
} );
it( "discards the edit and switches when Continue without saving is clicked", async() => {
render( );
await openEditAndSwitch();
- fireEvent.click( screen.getByRole( "button", { name: "Continue without saving" } ) );
+ // Both clicks commit a field-set switch (setActiveFieldSet), which re-triggers usePosts via
+ // the needsImprovementFields dep. Wrap in act so the fetch microtask is flushed before assertions.
+ await act( async() => {
+ fireEvent.click( screen.getByRole( "button", { name: "Continue without saving" } ) );
+ } );
expect( screen.queryByText( "Unsaved changes" ) ).not.toBeInTheDocument();
expect( screen.getByRole( "tab", { name: "Social appearance" } ) ).toHaveAttribute( "aria-selected", "true" );
// Back on Search the row is no longer in edit mode.
- fireEvent.click( screen.getByRole( "tab", { name: "Search appearance" } ) );
- expect( screen.queryByRole( "textbox", { name: `SEO title for ${ rowTitle }` } ) ).not.toBeInTheDocument();
+ await act( async() => {
+ fireEvent.click( screen.getByRole( "tab", { name: "Search appearance" } ) );
+ } );
+ expect( screen.queryByRole( "combobox", { name: `SEO title for ${ rowTitle }` } ) ).not.toBeInTheDocument();
expect( screen.getByRole( "button", { name: `Edit ${ rowTitle }` } ) ).toBeEnabled();
} );
@@ -193,8 +199,8 @@ describe( "App", () => {
fireEvent.click( secondEdit );
// Both rows are in edit mode simultaneously.
- expect( screen.getByRole( "textbox", { name: "SEO title for What Is SEO and How It Works" } ) ).toBeInTheDocument();
- expect( screen.getByRole( "textbox", { name: "SEO title for Keyword Research for Beginners" } ) ).toBeInTheDocument();
+ expect( screen.getByRole( "combobox", { name: "SEO title for What Is SEO and How It Works" } ) ).toBeInTheDocument();
+ expect( screen.getByRole( "combobox", { name: "SEO title for Keyword Research for Beginners" } ) ).toBeInTheDocument();
} );
describe( "saving a row (Save)", () => {
@@ -215,20 +221,18 @@ describe( "App", () => {
render( );
fireEvent.click( await screen.findByRole( "button", { name: `Edit ${ rowTitle }` } ) );
- fireEvent.change(
- screen.getByRole( "textbox", { name: `SEO title for ${ rowTitle }` } ),
- { target: { value: "New SEO title" } }
- );
- fireEvent.click( screen.getByRole( "button", { name: `Save ${ rowTitle }` } ) );
+ await act( async() => {
+ fireEvent.click( screen.getByRole( "button", { name: `Save ${ rowTitle }` } ) );
+ } );
// onApplyRow batches all open fields into one POST; Edit opens all fields in the active field set.
expect( remote.fetchJson ).toHaveBeenCalledWith(
endpointUrl,
{},
- { method: "POST", body: JSON.stringify( { items: [ { id: 1, [ focusKeyphraseParam ]: "what is seo", [ seoTitleParam ]: "New SEO title", [ metaDescriptionParam ]: "Learn what SEO is." } ] } ) }
+ { method: "POST", body: JSON.stringify( { items: [ { id: 1, [ focusKeyphraseParam ]: "what is seo", [ seoTitleParam ]: "What Is SEO? Complete Guide", [ metaDescriptionParam ]: "Learn what SEO is." } ] } ) }
);
// On success the field collapses/closes back to text.
- await waitFor( () => expect( screen.queryByRole( "textbox", { name: `SEO title for ${ rowTitle }` } ) ).not.toBeInTheDocument() );
+ await waitFor( () => expect( screen.queryByRole( "combobox", { name: `SEO title for ${ rowTitle }` } ) ).not.toBeInTheDocument() );
} );
it( "keeps the field open and re-enables it when the save fails", async() => {
@@ -239,14 +243,14 @@ describe( "App", () => {
fireEvent.click( screen.getByRole( "button", { name: `Save ${ rowTitle }` } ) );
// The field stays open and becomes editable again once the failed save settles.
- await waitFor( () => expect( screen.getByRole( "textbox", { name: `SEO title for ${ rowTitle }` } ) ).toBeEnabled() );
+ await waitFor( () => expect( screen.getByRole( "combobox", { name: `SEO title for ${ rowTitle }` } ) ).toBeEnabled() );
} );
it( "does not post to the save endpoint when it is unavailable", async() => {
const remote = buildRemote();
render( );
-
fireEvent.click( await screen.findByRole( "button", { name: `Edit ${ rowTitle }` } ) );
+
fireEvent.click( screen.getByRole( "button", { name: `Save ${ rowTitle }` } ) );
// The active tab's save endpoint is not configured, so no POST is made and the field stays open.
@@ -255,7 +259,7 @@ describe( "App", () => {
expect.anything(),
expect.objectContaining( { method: "POST" } )
);
- expect( screen.getByRole( "textbox", { name: `SEO title for ${ rowTitle }` } ) ).toBeInTheDocument();
+ expect( screen.getByRole( "combobox", { name: `SEO title for ${ rowTitle }` } ) ).toBeInTheDocument();
} );
} );
@@ -266,7 +270,7 @@ describe( "App", () => {
render( );
fireEvent.click( await screen.findByRole( "button", { name: `Edit ${ rowTitle }` } ) );
- expect( screen.getByRole( "textbox", { name: `SEO title for ${ rowTitle }` } ) ).toBeInTheDocument();
+ expect( screen.getByRole( "combobox", { name: `SEO title for ${ rowTitle }` } ) ).toBeInTheDocument();
fireEvent.click( screen.getByRole( "button", { name: "Posts" } ) );
expect( screen.getByText( "Unsaved changes" ) ).toBeInTheDocument();
@@ -312,7 +316,7 @@ describe( "App", () => {
// Enter edit mode so any spurious switch would be guarded by the modal.
fireEvent.click( await screen.findByRole( "button", { name: `Edit ${ rowTitle }` } ) );
- expect( screen.getByRole( "textbox", { name: `SEO title for ${ rowTitle }` } ) ).toBeInTheDocument();
+ expect( screen.getByRole( "combobox", { name: `SEO title for ${ rowTitle }` } ) ).toBeInTheDocument();
// "Pages" is the resolved default while the stored active name is still "" (never switched).
expect( screen.getByRole( "button", { name: "Pages" } ) ).toHaveAttribute( "aria-current", "page" );
@@ -320,7 +324,7 @@ describe( "App", () => {
// Clicking the content type you are already on is a no-op: no confirmation modal, the edit stays open.
expect( screen.queryByText( "Unsaved changes" ) ).not.toBeInTheDocument();
- expect( screen.getByRole( "textbox", { name: `SEO title for ${ rowTitle }` } ) ).toBeInTheDocument();
+ expect( screen.getByRole( "combobox", { name: `SEO title for ${ rowTitle }` } ) ).toBeInTheDocument();
} );
} );
@@ -357,7 +361,7 @@ describe( "App", () => {
render( );
fireEvent.click( await screen.findByRole( "button", { name: `Edit ${ rowTitle }` } ) );
- expect( screen.getByRole( "textbox", { name: `SEO title for ${ rowTitle }` } ) ).toBeInTheDocument();
+ expect( screen.getByRole( "combobox", { name: `SEO title for ${ rowTitle }` } ) ).toBeInTheDocument();
fireEvent.click( screen.getByRole( "link", { name: "Back to Tools" } ) );
diff --git a/packages/js/tests/bulk-editor/bulk-editor-table.test.js b/packages/js/tests/bulk-editor/bulk-editor-table.test.js
index 8ebae9bfdbf..c2e903ce3ec 100644
--- a/packages/js/tests/bulk-editor/bulk-editor-table.test.js
+++ b/packages/js/tests/bulk-editor/bulk-editor-table.test.js
@@ -1,8 +1,18 @@
-import { fireEvent, render, screen } from "../test-utils";
+import { fireEvent, render, screen, act } from "../test-utils";
import { BulkEditorTable } from "../../src/bulk-editor/components/table/bulk-editor-table";
import { FIELD_SET_SEARCH, FIELD_SET_SOCIAL, PAGE_SIZE } from "../../src/bulk-editor/constants";
import { getFieldSets } from "../../src/bulk-editor/field-sets";
+// BulkEditorRow calls useSelect to fetch replacement variables from the bulk-editor store.
+// This mock avoids registering the real store in unit tests and returns empty arrays.
+jest.mock( "@wordpress/data", () => ( {
+ useSelect: jest.fn( ( mapSelect ) => mapSelect( () => ( {
+ selectActiveContentTypeName: () => "",
+ selectReplacementVariablesFor: () => [],
+ selectRecommendedReplacementVariablesFor: () => [],
+ } ) ) ),
+} ) );
+
const fieldSets = getFieldSets();
const searchFieldSet = fieldSets[ FIELD_SET_SEARCH ];
const socialFieldSet = fieldSets[ FIELD_SET_SOCIAL ];
@@ -19,6 +29,7 @@ const items = [
socialTitle: "Social: What Is SEO",
socialDescription: "Social description for SEO.",
editable: true,
+ type: "description",
},
{
id: 2,
@@ -31,6 +42,7 @@ const items = [
socialTitle: "Social: On-Page SEO",
socialDescription: "Social description for on-page.",
editable: true,
+ type: "description",
},
];
@@ -48,7 +60,8 @@ describe( "BulkEditorTable", () => {
// Row data for the Search field set.
expect( screen.getByText( "What Is SEO? Complete Guide" ) ).toBeInTheDocument();
expect( screen.getByText( "Learn what SEO is." ) ).toBeInTheDocument();
- expect( screen.getByRole( "cell", { name: "What Is SEO? Complete Guide" } ) ).toHaveClass( "yst-bulk-editor-cell-value" );
+ // The seoTitle cell is identified by its aria label (the sr-only span + combobox label).
+ expect( screen.getByRole( "cell", { name: "SEO title for What Is SEO" } ) ).toHaveClass( "yst-bulk-editor-cell-value" );
} );
it( "renders the Social field set columns and values", () => {
@@ -100,8 +113,9 @@ describe( "BulkEditorTable", () => {
expect( screen.getByRole( "checkbox", { name: "Select What Is SEO" } ) ).toBeChecked();
expect( screen.getByRole( "checkbox", { name: "Select On-Page SEO Checklist" } ) ).not.toBeChecked();
-
- fireEvent.click( screen.getByRole( "checkbox", { name: "Select On-Page SEO Checklist" } ) );
+ act( () => {
+ fireEvent.click( screen.getByRole( "checkbox", { name: "Select On-Page SEO Checklist" } ) );
+ } );
expect( onToggleRow ).toHaveBeenCalledWith( 2 );
} );
@@ -111,7 +125,9 @@ describe( "BulkEditorTable", () => {
// Accessible names are contextual, so there is no ambiguous "Edit" button.
expect( screen.queryByRole( "button", { name: "Edit" } ) ).not.toBeInTheDocument();
- fireEvent.click( screen.getByRole( "button", { name: "Edit On-Page SEO Checklist" } ) );
+ act( () => {
+ fireEvent.click( screen.getByRole( "button", { name: "Edit On-Page SEO Checklist" } ) );
+ } );
expect( onStartEdit ).toHaveBeenCalledWith( 2 );
} );
@@ -162,7 +178,9 @@ describe( "BulkEditorTable", () => {
expect( screen.getByRole( "checkbox", { name: "Select What Is SEO" } ) ).toBeDisabled();
expect( screen.getByRole( "button", { name: "Edit What Is SEO" } ) ).toBeDisabled();
- fireEvent.click( screen.getByRole( "button", { name: "Edit What Is SEO" } ) );
+ act( () => {
+ fireEvent.click( screen.getByRole( "button", { name: "Edit What Is SEO" } ) );
+ } );
expect( onToggleRow ).not.toHaveBeenCalled();
} );
@@ -233,7 +251,7 @@ describe( "BulkEditorTable", () => {
expect( screen.getByRole( "table" ).className ).not.toContain( "yst-rounded-none" );
} );
- it( "renders a textarea per open field with a single row-level Save and Cancel", () => {
+ it( "renders editable fields per open field with a single row-level Save and Cancel", () => {
render(
{
/>
);
- const title = screen.getByRole( "textbox", { name: "SEO title for On-Page SEO Checklist" } );
- const description = screen.getByRole( "textbox", { name: "Meta description for On-Page SEO Checklist" } );
- expect( title ).toHaveValue( "Draft title" );
- expect( description ).toHaveValue( "Draft description" );
- // Equal-height two-line fields per the design (full text view, no scrollbar).
- expect( title.tagName ).toBe( "TEXTAREA" );
- expect( description.tagName ).toBe( "TEXTAREA" );
- expect( title ).toHaveAttribute( "rows", "2" );
+ const title = screen.getByRole( "combobox", { name: "SEO title for On-Page SEO Checklist" } );
+ const description = screen.getByRole( "combobox", { name: "Meta description for On-Page SEO Checklist" } );
+ // seoTitle and metaDescription use the replacement-variable editor (DraftJS combobox).
+ expect( title ).toHaveTextContent( "Draft title" );
+ expect( description ).toHaveTextContent( "Draft description" );
// No per-field Apply/Discard: the row has a single Save and Cancel.
expect( screen.queryByRole( "button", { name: "Apply SEO title for On-Page SEO Checklist" } ) ).not.toBeInTheDocument();
@@ -277,8 +292,9 @@ describe( "BulkEditorTable", () => {
} }
/>
);
-
- fireEvent.click( screen.getByRole( "button", { name: "Cancel editing On-Page SEO Checklist" } ) );
+ act( () => {
+ fireEvent.click( screen.getByRole( "button", { name: "Cancel editing On-Page SEO Checklist" } ) );
+ } );
expect( onCancelEdit ).toHaveBeenCalledWith( 2 );
} );
@@ -305,9 +321,9 @@ describe( "BulkEditorTable", () => {
/>
);
- expect( screen.getByRole( "textbox", { name: "Meta description for On-Page SEO Checklist" } ) ).toBeInTheDocument();
+ expect( screen.getByRole( "combobox", { name: "Meta description for On-Page SEO Checklist" } ) ).toBeInTheDocument();
// The SEO title was resolved/closed, so it is no longer an input.
- expect( screen.queryByRole( "textbox", { name: "SEO title for On-Page SEO Checklist" } ) ).not.toBeInTheDocument();
+ expect( screen.queryByRole( "combobox", { name: "SEO title for On-Page SEO Checklist" } ) ).not.toBeInTheDocument();
} );
it( "calls onChangeField on input, and onApplyRow with the row id when Save is clicked", () => {
@@ -318,27 +334,31 @@ describe( "BulkEditorTable", () => {
items={ items }
fieldSet={ searchFieldSet }
editing={ {
- editingRows: { 2: { openFields: [ "seoTitle", "metaDescription" ], draft: { seoTitle: "Draft title", metaDescription: "Draft description" }, savingFields: {} } },
+ editingRows: { 2: { openFields: [ "focusKeyphrase", "seoTitle" ], draft: { focusKeyphrase: "draft keyphrase", seoTitle: "Draft title" }, savingFields: {} } },
onChangeField,
onApplyRow,
} }
/>
);
-
- fireEvent.change(
- screen.getByRole( "textbox", { name: "SEO title for On-Page SEO Checklist" } ),
- { target: { value: "Changed" } }
- );
- expect( onChangeField ).toHaveBeenCalledWith( { id: 2, key: "seoTitle", value: "Changed" } );
-
+ act( () => {
+ // focusKeyphrase is a plain textarea — fireEvent.change works here.
+ fireEvent.change(
+ screen.getByRole( "textbox", { name: "Focus keyphrase for On-Page SEO Checklist" } ),
+ { target: { value: "Changed" } }
+ );
+ } );
+ expect( onChangeField ).toHaveBeenCalledWith( { id: 2, key: "focusKeyphrase", value: "Changed" } );
+
+ act( () => {
// Save delegates to onApplyRow, which batches all open fields into one request.
- fireEvent.click( screen.getByRole( "button", { name: "Save On-Page SEO Checklist" } ) );
+ fireEvent.click( screen.getByRole( "button", { name: "Save On-Page SEO Checklist" } ) );
+ } );
expect( onApplyRow ).toHaveBeenCalledTimes( 1 );
expect( onApplyRow ).toHaveBeenCalledWith( 2 );
} );
it( "disables the row's inputs and actions while it is saving", () => {
- render(
+ const { container } = render(
{
);
// While any field on the row is saving, the whole row is locked.
- expect( screen.getByRole( "textbox", { name: "SEO title for On-Page SEO Checklist" } ) ).toBeDisabled();
- expect( screen.getByRole( "textbox", { name: "Meta description for On-Page SEO Checklist" } ) ).toBeDisabled();
+ // Draft.js 0.11 sets contentEditable=false and drops role/aria-readonly in readOnly mode;
+ // ReplacementVariableEditor marks the wrapper with yst-replacevar--disabled instead.
+ expect( container.querySelectorAll( ".yst-replacevar--disabled" ) ).toHaveLength( 2 );
expect( screen.getByRole( "button", { name: "Save On-Page SEO Checklist" } ) ).toBeDisabled();
expect( screen.getByRole( "button", { name: "Cancel editing On-Page SEO Checklist" } ) ).toBeDisabled();
} );
@@ -385,7 +406,7 @@ describe( "BulkEditorTable", () => {
/>
);
- expect( screen.getByRole( "textbox", { name: "SEO title for What Is SEO" } ) ).toHaveValue( "First" );
- expect( screen.getByRole( "textbox", { name: "SEO title for On-Page SEO Checklist" } ) ).toHaveValue( "Second" );
+ expect( screen.getByRole( "combobox", { name: "SEO title for What Is SEO" } ) ).toHaveTextContent( "First" );
+ expect( screen.getByRole( "combobox", { name: "SEO title for On-Page SEO Checklist" } ) ).toHaveTextContent( "Second" );
} );
} );
diff --git a/packages/js/tests/bulk-editor/field-sets.test.js b/packages/js/tests/bulk-editor/field-sets.test.js
index c8ee944f070..ce1e8a5cea8 100644
--- a/packages/js/tests/bulk-editor/field-sets.test.js
+++ b/packages/js/tests/bulk-editor/field-sets.test.js
@@ -23,8 +23,8 @@ describe( "getFieldSets", () => {
expect( fieldSet.endpoint ).toBe( "update_search" );
expect( fieldSet.fields ).toEqual( [
{ key: "focusKeyphrase", label: "Focus keyphrase", param: "focus_keyphrase", width: "sm:yst-w-[19%]" },
- { key: "seoTitle", label: "SEO title", param: "seo_title", width: "sm:yst-w-[19%]" },
- { key: "metaDescription", label: "Meta description", param: "meta_description", width: "sm:yst-w-[33%]" },
+ { key: "seoTitle", label: "SEO title", param: "seo_title", width: "sm:yst-w-[19%]", type: "title" },
+ { key: "metaDescription", label: "Meta description", param: "meta_description", width: "sm:yst-w-[33%]", type: "description" },
] );
} );
@@ -34,8 +34,8 @@ describe( "getFieldSets", () => {
expect( fieldSet.endpoint ).toBe( "update_social" );
expect( fieldSet.fields ).toEqual( [
{ key: "focusKeyphrase", label: "Focus keyphrase", param: "focus_keyphrase", width: "sm:yst-w-[19%]" },
- { key: "socialTitle", label: "Social title", param: "social_title", width: "sm:yst-w-[19%]" },
- { key: "socialDescription", label: "Social description", param: "social_description", width: "sm:yst-w-[33%]" },
+ { key: "socialTitle", label: "Social title", param: "social_title", width: "sm:yst-w-[19%]", type: "title" },
+ { key: "socialDescription", label: "Social description", param: "social_description", width: "sm:yst-w-[33%]", type: "description" },
] );
} );
diff --git a/packages/js/tests/bulk-editor/hooks/use-posts.test.js b/packages/js/tests/bulk-editor/hooks/use-posts.test.js
index 8953eda2f14..bd44d8eafc7 100644
--- a/packages/js/tests/bulk-editor/hooks/use-posts.test.js
+++ b/packages/js/tests/bulk-editor/hooks/use-posts.test.js
@@ -149,6 +149,10 @@ describe( "usePosts", () => {
meta_description: "A description.",
social_title: "Social hello",
social_description: "Social description.",
+ seo_title_fallback: "",
+ meta_description_fallback: "",
+ social_title_fallback: "",
+ social_description_fallback: "",
editable: true,
needs_improvement: { seo_title: false, meta_description: true, social_title: false, social_description: false },
},
@@ -174,6 +178,10 @@ describe( "usePosts", () => {
metaDescription: "A description.",
socialTitle: "Social hello",
socialDescription: "Social description.",
+ seoTitleFallback: "",
+ metaDescriptionFallback: "",
+ socialTitleFallback: "",
+ socialDescriptionFallback: "",
editable: true,
// eslint-disable-next-line camelcase -- the needs-improvement map is keyed by backend field params.
needsImprovement: { seo_title: false, meta_description: true, social_title: false, social_description: false },
diff --git a/packages/js/tests/bulk-editor/initialize.test.js b/packages/js/tests/bulk-editor/initialize.test.js
index 804703a9b5b..e88239f66c1 100644
--- a/packages/js/tests/bulk-editor/initialize.test.js
+++ b/packages/js/tests/bulk-editor/initialize.test.js
@@ -112,10 +112,19 @@ describe( "bulk editor initialize", () => {
connectUrl: null,
learnMoreUrl: "",
},
+ replacementVariables: {
+ recommended: {},
+ shared: [],
+ specific: {},
+ variables: [],
+ },
optInNotification: { seen: { [ TOUR_OPT_IN_KEY ]: true } },
- query: { overviewIds: [], isOverviewFilterActive: false },
- activeContentType: "",
+ activeContentType: "post",
selection: { selectedIds: [], preselectedTotal: 0 },
+ query: {
+ isOverviewFilterActive: false,
+ overviewIds: [],
+ },
},
} );
expect( mockFixScrolling ).toHaveBeenCalledTimes( 1 );
@@ -142,6 +151,7 @@ describe( "bulk editor initialize", () => {
connectUrl: null,
learnMoreUrl: "",
},
+ replacementVariables: { recommended: {}, shared: [], specific: {}, variables: [] },
activeContentType: "page",
selection: { selectedIds: [ 5, 3 ], preselectedTotal: 25 },
query: { overviewIds: [ 5, 3 ], isOverviewFilterActive: true },
diff --git a/src/bulk-editor/domain/posts/post.php b/src/bulk-editor/domain/posts/post.php
index 8177bf85308..405043cef87 100644
--- a/src/bulk-editor/domain/posts/post.php
+++ b/src/bulk-editor/domain/posts/post.php
@@ -44,28 +44,28 @@ class Post {
private $focus_keyphrase;
/**
- * The SEO title.
+ * The raw stored SEO title (empty string when never explicitly saved).
*
* @var string
*/
private $seo_title;
/**
- * The meta description.
+ * The raw stored meta description (empty string when never explicitly saved).
*
* @var string
*/
private $meta_description;
/**
- * The social title.
+ * The raw stored social title (empty string when never explicitly saved).
*
* @var string
*/
private $social_title;
/**
- * The social description.
+ * The raw stored social description (empty string when never explicitly saved).
*
* @var string
*/
@@ -86,20 +86,52 @@ class Post {
*/
private $needs_improvement;
+ /**
+ * The post type's SEO title template, shown when the stored value is empty. Empty string when the stored value is set.
+ *
+ * @var string
+ */
+ private $seo_title_fallback;
+
+ /**
+ * The post type's meta description template, shown when the stored value is empty. Empty string when the stored value is set.
+ *
+ * @var string
+ */
+ private $meta_description_fallback;
+
+ /**
+ * The post type's social title template, shown when the stored value is empty. Empty string when the stored value is set.
+ *
+ * @var string
+ */
+ private $social_title_fallback;
+
+ /**
+ * The post type's social description template, shown when the stored value is empty. Empty string when the stored value is set.
+ *
+ * @var string
+ */
+ private $social_description_fallback;
+
/**
* The constructor.
*
- * @param int $id The post ID.
- * @param string $title The post title.
- * @param string $status The post status.
- * @param string $edit_link The URL to edit the post.
- * @param string $focus_keyphrase The focus keyphrase.
- * @param string $seo_title The SEO title.
- * @param string $meta_description The meta description.
- * @param string $social_title The social title.
- * @param string $social_description The social description.
- * @param bool $editable Whether the current user may edit this post.
- * @param array $needs_improvement Whether each field needs improvement, keyed by field param.
+ * @param int $id The post ID.
+ * @param string $title The post title.
+ * @param string $status The post status.
+ * @param string $edit_link The URL to edit the post.
+ * @param string $focus_keyphrase The focus keyphrase.
+ * @param string $seo_title The raw stored SEO title.
+ * @param string $meta_description The raw stored meta description.
+ * @param string $social_title The raw stored social title.
+ * @param string $social_description The raw stored social description.
+ * @param bool $editable Whether the current user may edit this post.
+ * @param array $needs_improvement Whether each field needs improvement, keyed by field param.
+ * @param string $seo_title_fallback The post type's SEO title template (empty when stored value is set).
+ * @param string $meta_description_fallback The post type's meta description template (empty when stored value is set).
+ * @param string $social_title_fallback The post type's social title template (empty when stored value is set).
+ * @param string $social_description_fallback The post type's social description template (empty when stored value is set).
*/
public function __construct(
int $id,
@@ -112,19 +144,27 @@ public function __construct(
string $social_title,
string $social_description,
bool $editable,
- array $needs_improvement = []
+ array $needs_improvement = [],
+ string $seo_title_fallback = '',
+ string $meta_description_fallback = '',
+ string $social_title_fallback = '',
+ string $social_description_fallback = ''
) {
- $this->id = $id;
- $this->title = $title;
- $this->status = $status;
- $this->edit_link = $edit_link;
- $this->focus_keyphrase = $focus_keyphrase;
- $this->seo_title = $seo_title;
- $this->meta_description = $meta_description;
- $this->social_title = $social_title;
- $this->social_description = $social_description;
- $this->editable = $editable;
- $this->needs_improvement = $needs_improvement;
+ $this->id = $id;
+ $this->title = $title;
+ $this->status = $status;
+ $this->edit_link = $edit_link;
+ $this->focus_keyphrase = $focus_keyphrase;
+ $this->seo_title = $seo_title;
+ $this->meta_description = $meta_description;
+ $this->social_title = $social_title;
+ $this->social_description = $social_description;
+ $this->editable = $editable;
+ $this->needs_improvement = $needs_improvement;
+ $this->seo_title_fallback = $seo_title_fallback;
+ $this->meta_description_fallback = $meta_description_fallback;
+ $this->social_title_fallback = $social_title_fallback;
+ $this->social_description_fallback = $social_description_fallback;
}
/**
@@ -134,17 +174,21 @@ public function __construct(
*/
public function to_array(): array {
return [
- 'id' => $this->id,
- 'title' => $this->title,
- 'status' => $this->status,
- 'edit_link' => $this->edit_link,
- 'focus_keyphrase' => $this->focus_keyphrase,
- 'seo_title' => $this->seo_title,
- 'meta_description' => $this->meta_description,
- 'social_title' => $this->social_title,
- 'social_description' => $this->social_description,
- 'editable' => $this->editable,
- 'needs_improvement' => \array_merge(
+ 'id' => $this->id,
+ 'title' => $this->title,
+ 'status' => $this->status,
+ 'edit_link' => $this->edit_link,
+ 'focus_keyphrase' => $this->focus_keyphrase,
+ 'seo_title' => $this->seo_title,
+ 'meta_description' => $this->meta_description,
+ 'social_title' => $this->social_title,
+ 'social_description' => $this->social_description,
+ 'seo_title_fallback' => $this->seo_title_fallback,
+ 'meta_description_fallback' => $this->meta_description_fallback,
+ 'social_title_fallback' => $this->social_title_fallback,
+ 'social_description_fallback' => $this->social_description_fallback,
+ 'editable' => $this->editable,
+ 'needs_improvement' => \array_merge(
[
'seo_title' => false,
'meta_description' => false,
diff --git a/src/bulk-editor/infrastructure/posts/default-template-resolver.php b/src/bulk-editor/infrastructure/posts/default-template-resolver.php
new file mode 100644
index 00000000000..d55d1adf7ce
--- /dev/null
+++ b/src/bulk-editor/infrastructure/posts/default-template-resolver.php
@@ -0,0 +1,141 @@
+options_helper = $options_helper;
+ }
+
+ /**
+ * Returns the raw SEO title template for a post, falling back to the post type's configured template when empty.
+ *
+ * Returns the unresolved template string (e.g. `%%title%% %%sep%% %%sitename%%`) so the caller
+ * can display it in a replacement-variable editor rather than showing the expanded value.
+ * Priority: stored value → user-configured post type template (`title-{post_type}`) → installation default.
+ *
+ * @param int $post_id The post ID (unused; kept for a consistent method signature).
+ * @param string $post_type The post type slug.
+ * @param string $stored_value The raw stored title (empty string when never explicitly saved).
+ *
+ * @return string The raw template string, or an empty string when no template is configured.
+ */
+ public function resolve_seo_title( int $post_id, string $post_type, string $stored_value ): string {
+ return $this->resolve( $post_type, $stored_value, 'title-', true );
+ }
+
+ /**
+ * Returns the raw meta description template for a post, falling back to the post type's configured template when empty.
+ *
+ * Returns the unresolved template string so the caller can display it in a replacement-variable
+ * editor. Unlike SEO title there is no installation-level default, so an empty stored value
+ * returns an empty string when the user has not configured a post type template.
+ *
+ * @param int $post_id The post ID (unused; kept for a consistent method signature).
+ * @param string $post_type The post type slug.
+ * @param string $stored_value The raw stored description (empty string when never explicitly saved).
+ *
+ * @return string The raw template string, or an empty string when no template is configured.
+ */
+ public function resolve_meta_description( int $post_id, string $post_type, string $stored_value ): string {
+ return $this->resolve( $post_type, $stored_value, 'metadesc-', false );
+ }
+
+ /**
+ * Returns the raw social title template for a post, falling back to the post type's configured template when empty.
+ *
+ * Returns the unresolved template string so the caller can display it in a replacement-variable editor.
+ * Only resolves a template when OpenGraph is enabled.
+ * Priority: stored value → user-configured post type template (`social-title-{post_type}`) → installation default.
+ *
+ * @param int $post_id The post ID (unused; kept for a consistent method signature).
+ * @param string $post_type The post type slug.
+ * @param string $stored_value The raw stored social title (empty string when never explicitly saved).
+ *
+ * @return string The raw template string, or an empty string when no template is configured.
+ */
+ public function resolve_social_title( int $post_id, string $post_type, string $stored_value ): string {
+ if ( $stored_value !== '' ) {
+ return $stored_value;
+ }
+
+ if ( $this->options_helper->get( 'opengraph', false ) !== true ) {
+ return '';
+ }
+
+ return $this->resolve( $post_type, '', 'social-title-', true );
+ }
+
+ /**
+ * Returns the raw social description template for a post, falling back to the post type's configured template when empty.
+ *
+ * Returns the unresolved template string so the caller can display it in a replacement-variable editor.
+ * Only resolves a template when OpenGraph is enabled. Unlike social title there is no installation-level default.
+ *
+ * @param int $post_id The post ID (unused; kept for a consistent method signature).
+ * @param string $post_type The post type slug.
+ * @param string $stored_value The raw stored social description (empty string when never explicitly saved).
+ *
+ * @return string The raw template string, or an empty string when no template is configured.
+ */
+ public function resolve_social_description( int $post_id, string $post_type, string $stored_value ): string {
+ if ( $stored_value !== '' ) {
+ return $stored_value;
+ }
+
+ if ( $this->options_helper->get( 'opengraph', false ) !== true ) {
+ return '';
+ }
+
+ return $this->resolve( $post_type, '', 'social-description-', false );
+ }
+
+ /**
+ * Resolves a raw template string for a given post type, stored value, and option key prefix.
+ *
+ * @param string $post_type The post type slug.
+ * @param string $stored_value The raw stored value (empty string when never explicitly saved).
+ * @param string $option_key_prefix The option key prefix (e.g. `title-`, `metadesc-`).
+ * @param bool $use_installation_default Whether to fall back to the installation default when the configured template is empty.
+ *
+ * @return string The raw template string, or an empty string when no template is configured.
+ */
+ private function resolve( string $post_type, string $stored_value, string $option_key_prefix, bool $use_installation_default ): string {
+ if ( $stored_value !== '' ) {
+ return $stored_value;
+ }
+
+ $option_key = $option_key_prefix . $post_type;
+ $template = (string) $this->options_helper->get( $option_key, '' );
+
+ if ( $use_installation_default && $template === '' ) {
+ $template = (string) $this->options_helper->get_title_default( $option_key );
+ }
+
+ return $template;
+ }
+}
diff --git a/src/bulk-editor/infrastructure/posts/indexable-posts-collector.php b/src/bulk-editor/infrastructure/posts/indexable-posts-collector.php
index 79497d69785..22a34d45b23 100644
--- a/src/bulk-editor/infrastructure/posts/indexable-posts-collector.php
+++ b/src/bulk-editor/infrastructure/posts/indexable-posts-collector.php
@@ -60,18 +60,28 @@ class Indexable_Posts_Collector implements Posts_Collector_Interface {
*/
private $post_editability_resolver;
+ /**
+ * The resolver for the post type's default SEO title / meta description template.
+ *
+ * @var Default_Template_Resolver
+ */
+ private $default_template_resolver;
+
/**
* The constructor.
*
* @param Indexable_Repository $indexable_repository The indexable repository.
* @param Post_Editability_Resolver $post_editability_resolver The resolver for the per-post edit permission.
+ * @param Default_Template_Resolver $default_template_resolver The resolver for the default SEO title / meta description template.
*/
public function __construct(
Indexable_Repository $indexable_repository,
- Post_Editability_Resolver $post_editability_resolver
+ Post_Editability_Resolver $post_editability_resolver,
+ Default_Template_Resolver $default_template_resolver
) {
$this->indexable_repository = $indexable_repository;
$this->post_editability_resolver = $post_editability_resolver;
+ $this->default_template_resolver = $default_template_resolver;
}
/**
@@ -162,7 +172,7 @@ private function build_query( Posts_Query $query ): ORM {
}
if ( $query->get_needs_improvement() !== [] ) {
- $this->apply_needs_improvement( $builder, $query->get_needs_improvement(), $query->are_scores_enabled() );
+ $this->apply_needs_improvement( $builder, $query->get_needs_improvement(), $query->are_scores_enabled(), $query->get_content_type() );
}
return $builder;
@@ -173,16 +183,26 @@ private function build_query( Posts_Query $query ): ORM {
*
* A field needs improvement when its indexable column is NULL or an empty string, or — for fields
* with a persisted per-field score and while scoring is enabled — when that score falls in the bad/ok
- * range. The selected fields are OR-ed inside a single group so they broaden the result without
- * interfering with the other filters, and unknown field keys are ignored.
+ * range. The empty-value check is skipped for fields whose post type has a configured fallback template,
+ * since template-defaulted posts are not genuinely empty. The selected fields are OR-ed inside a single
+ * group so they broaden the result without interfering with the other filters, and unknown field keys
+ * are ignored.
*
* @param ORM $builder The query to add the clause to.
* @param array $fields The fields that need improvement.
* @param bool $scores_enabled Whether the per-field scores may back the filter.
+ * @param string $post_type The post type slug.
*
* @return void
*/
- private function apply_needs_improvement( ORM $builder, array $fields, bool $scores_enabled ): void {
+ private function apply_needs_improvement( ORM $builder, array $fields, bool $scores_enabled, string $post_type ): void {
+ $has_fallback = [
+ 'seo_title' => $this->default_template_resolver->resolve_seo_title( 0, $post_type, '' ) !== '',
+ 'meta_description' => $this->default_template_resolver->resolve_meta_description( 0, $post_type, '' ) !== '',
+ 'social_title' => $this->default_template_resolver->resolve_social_title( 0, $post_type, '' ) !== '',
+ 'social_description' => $this->default_template_resolver->resolve_social_description( 0, $post_type, '' ) !== '',
+ ];
+
$clauses = [];
$values = [];
foreach ( $fields as $field ) {
@@ -190,17 +210,23 @@ private function apply_needs_improvement( ORM $builder, array $fields, bool $sco
continue;
}
- $column = self::FIELD_COLUMNS[ $field ];
- $clause = $column . ' IS NULL OR ' . $column . ' = %s';
- $values[] = '';
+ $column = self::FIELD_COLUMNS[ $field ];
+ $field_clauses = [];
+
+ if ( ! ( $has_fallback[ $field ] ?? false ) ) {
+ $field_clauses[] = $column . ' IS NULL OR ' . $column . ' = %s';
+ $values[] = '';
+ }
if ( $scores_enabled && isset( self::FIELD_SCORE_COLUMNS[ $field ] ) ) {
- $clause .= ' OR ' . self::FIELD_SCORE_COLUMNS[ $field ] . ' BETWEEN %d AND %d';
- $values[] = self::NEEDS_IMPROVEMENT_MIN_SCORE;
- $values[] = self::NEEDS_IMPROVEMENT_MAX_SCORE;
+ $field_clauses[] = self::FIELD_SCORE_COLUMNS[ $field ] . ' BETWEEN %d AND %d';
+ $values[] = self::NEEDS_IMPROVEMENT_MIN_SCORE;
+ $values[] = self::NEEDS_IMPROVEMENT_MAX_SCORE;
}
- $clauses[] = '( ' . $clause . ' )';
+ // Always add a clause per field — use a false condition when no real predicate applies so the
+ // field still participates in the outer OR group without incorrectly matching every row.
+ $clauses[] = '( ' . ( ( $field_clauses !== [] ) ? \implode( ' OR ', $field_clauses ) : '1 = 0' ) . ' )';
}
if ( $clauses === [] ) {
@@ -264,18 +290,37 @@ private function build_post( Indexable $indexable, bool $editable, bool $scores_
return new Post( $object_id, $title, (string) $indexable->post_status, '', '', '', '', '', '', false );
}
+ $post_type = (string) $indexable->object_sub_type;
+
+ $raw_seo_title = (string) $indexable->title;
+ $raw_meta_description = (string) $indexable->description;
+ $raw_social_title = (string) $indexable->open_graph_title;
+ $raw_social_description = (string) $indexable->open_graph_description;
+
+ // Resolver results are used for needs-improvement scoring and as display fallbacks when the stored value is empty.
+ $resolved_values = [
+ 'seo_title' => $this->default_template_resolver->resolve_seo_title( $object_id, $post_type, $raw_seo_title ),
+ 'meta_description' => $this->default_template_resolver->resolve_meta_description( $object_id, $post_type, $raw_meta_description ),
+ 'social_title' => $this->default_template_resolver->resolve_social_title( $object_id, $post_type, $raw_social_title ),
+ 'social_description' => $this->default_template_resolver->resolve_social_description( $object_id, $post_type, $raw_social_description ),
+ ];
+
return new Post(
$object_id,
$title,
(string) $indexable->post_status,
(string) \get_edit_post_link( $object_id, 'raw' ),
(string) $indexable->primary_focus_keyword,
- (string) $indexable->title,
- (string) $indexable->description,
- (string) $indexable->open_graph_title,
- (string) $indexable->open_graph_description,
+ $raw_seo_title,
+ $raw_meta_description,
+ $raw_social_title,
+ $raw_social_description,
true,
- $this->build_needs_improvement( $indexable, $scores_enabled ),
+ $this->build_needs_improvement( $indexable, $scores_enabled, $resolved_values ),
+ ( $raw_seo_title === '' ) ? $resolved_values['seo_title'] : '',
+ ( $raw_meta_description === '' ) ? $resolved_values['meta_description'] : '',
+ ( $raw_social_title === '' ) ? $resolved_values['social_title'] : '',
+ ( $raw_social_description === '' ) ? $resolved_values['social_description'] : '',
);
}
@@ -283,16 +328,20 @@ private function build_post( Indexable $indexable, bool $editable, bool $scores_
* Builds the per-field needs-improvement verdict for a post, keyed by field param.
*
* A field needs improvement when its value is empty, or when its score falls in the bad/ok range.
+ * All four display values are passed in already-resolved so that a post whose stored value is empty
+ * but whose post type has a configured default template is not incorrectly flagged.
*
- * @param Indexable $indexable The indexable.
- * @param bool $scores_enabled Whether the per-field scores may back the verdict.
+ * @param Indexable $indexable The indexable.
+ * @param bool $scores_enabled Whether the per-field scores may back the verdict.
+ * @param array $resolved_values The resolved display values, keyed by field param.
*
* @return array Whether each field needs improvement, keyed by field param.
*/
- private function build_needs_improvement( Indexable $indexable, bool $scores_enabled ): array {
+ private function build_needs_improvement( Indexable $indexable, bool $scores_enabled, array $resolved_values ): array {
$needs_improvement = [];
foreach ( self::FIELD_COLUMNS as $field => $column ) {
- $is_empty = ( (string) $indexable->{$column} === '' );
+ $value = $resolved_values[ $field ];
+ $is_empty = ( $value === '' );
$is_bad_score = false;
if ( $scores_enabled && isset( self::FIELD_SCORE_COLUMNS[ $field ] ) ) {
diff --git a/src/bulk-editor/infrastructure/posts/post-meta-posts-collector.php b/src/bulk-editor/infrastructure/posts/post-meta-posts-collector.php
index 12fe1d00459..2398c56ffe2 100644
--- a/src/bulk-editor/infrastructure/posts/post-meta-posts-collector.php
+++ b/src/bulk-editor/infrastructure/posts/post-meta-posts-collector.php
@@ -75,13 +75,25 @@ class Post_Meta_Posts_Collector implements Posts_Collector_Interface {
*/
private $post_editability_resolver;
+ /**
+ * The resolver for the post type's default SEO title / meta description template.
+ *
+ * @var Default_Template_Resolver
+ */
+ private $default_template_resolver;
+
/**
* The constructor.
*
* @param Post_Editability_Resolver $post_editability_resolver The resolver for the per-post edit permission.
+ * @param Default_Template_Resolver $default_template_resolver The resolver for the default SEO title / meta description template.
*/
- public function __construct( Post_Editability_Resolver $post_editability_resolver ) {
+ public function __construct(
+ Post_Editability_Resolver $post_editability_resolver,
+ Default_Template_Resolver $default_template_resolver
+ ) {
$this->post_editability_resolver = $post_editability_resolver;
+ $this->default_template_resolver = $default_template_resolver;
}
/**
@@ -124,7 +136,7 @@ protected function run_query( Posts_Query $query ): WP_Query {
$args = $this->build_query_args( $query );
$this->search_where = $query->has_search() ? $this->build_search_where( $query->get_search() ) : '';
- $this->needs_improvement_where = $this->build_needs_improvement_where( $query->get_needs_improvement(), $query->are_scores_enabled() );
+ $this->needs_improvement_where = $this->build_needs_improvement_where( $query->get_needs_improvement(), $query->are_scores_enabled(), $query->get_content_type() );
if ( $this->search_where === '' && $this->needs_improvement_where === '' ) {
return new WP_Query( $args );
@@ -209,9 +221,10 @@ public function filter_posts_where( $where, $wp_query ): string {
* @return Post The post.
*/
private function build_post( int $post_id, bool $editable, bool $scores_enabled ): Post {
- $post = \get_post( $post_id );
- $status = ( $post !== null ) ? (string) $post->post_status : '';
- $title = $this->get_normalized_title( $post_id );
+ $post = \get_post( $post_id );
+ $status = ( $post !== null ) ? (string) $post->post_status : '';
+ $post_type = ( $post !== null ) ? (string) $post->post_type : '';
+ $title = $this->get_normalized_title( $post_id );
if ( ! $editable ) {
return new Post( $post_id, $title, $status, '', '', '', '', '', '', false );
@@ -224,18 +237,33 @@ private function build_post( int $post_id, bool $editable, bool $scores_enabled
$fields[ $field ] = $this->get_meta( $post_id, $suffix );
}
+ $raw_seo_title = $fields['seo_title'];
+ $raw_meta_description = $fields['meta_description'];
+ $raw_social_title = $fields['social_title'];
+ $raw_social_description = $fields['social_description'];
+
+ // Resolve templates for needs-improvement scoring and as display fallbacks when the stored value is empty.
+ $fields['seo_title'] = $this->default_template_resolver->resolve_seo_title( $post_id, $post_type, $raw_seo_title );
+ $fields['meta_description'] = $this->default_template_resolver->resolve_meta_description( $post_id, $post_type, $raw_meta_description );
+ $fields['social_title'] = $this->default_template_resolver->resolve_social_title( $post_id, $post_type, $raw_social_title );
+ $fields['social_description'] = $this->default_template_resolver->resolve_social_description( $post_id, $post_type, $raw_social_description );
+
return new Post(
$post_id,
$title,
$status,
(string) \get_edit_post_link( $post_id, 'raw' ),
$this->get_meta( $post_id, 'focuskw' ),
- $fields['seo_title'],
- $fields['meta_description'],
- $fields['social_title'],
- $fields['social_description'],
+ $raw_seo_title,
+ $raw_meta_description,
+ $raw_social_title,
+ $raw_social_description,
true,
$this->build_needs_improvement( $post_id, $fields, $scores_enabled ),
+ ( $raw_seo_title === '' ) ? $fields['seo_title'] : '',
+ ( $raw_meta_description === '' ) ? $fields['meta_description'] : '',
+ ( $raw_social_title === '' ) ? $fields['social_title'] : '',
+ ( $raw_social_description === '' ) ? $fields['social_description'] : '',
);
}
@@ -281,45 +309,53 @@ private function build_needs_improvement( int $post_id, array $fields, bool $sco
*
* @param array $fields The fields that need improvement.
* @param bool $scores_enabled Whether the per-field scores may back the filter.
+ * @param string $post_type The post type slug.
*
* @return string The prepared WHERE clause, or an empty string when no known field is selected.
*/
- protected function build_needs_improvement_where( array $fields, bool $scores_enabled ): string {
+ protected function build_needs_improvement_where( array $fields, bool $scores_enabled, string $post_type = '' ): string {
global $wpdb;
+ $has_fallback = [
+ 'seo_title' => $this->default_template_resolver->resolve_seo_title( 0, $post_type, '' ) !== '',
+ 'meta_description' => $this->default_template_resolver->resolve_meta_description( 0, $post_type, '' ) !== '',
+ 'social_title' => $this->default_template_resolver->resolve_social_title( 0, $post_type, '' ) !== '',
+ 'social_description' => $this->default_template_resolver->resolve_social_description( 0, $post_type, '' ) !== '',
+ ];
+
$clauses = [];
foreach ( $fields as $field ) {
if ( ! isset( self::FIELD_META_SUFFIXES[ $field ] ) ) {
continue;
}
- $meta_key = self::META_PREFIX . self::FIELD_META_SUFFIXES[ $field ];
+ $meta_key = self::META_PREFIX . self::FIELD_META_SUFFIXES[ $field ];
+ $field_clauses = [];
- if ( $scores_enabled && isset( self::FIELD_SCORE_META_SUFFIXES[ $field ] ) ) {
- $clauses[] = $wpdb->prepare(
- '( %i.ID NOT IN ( SELECT post_id FROM %i WHERE meta_key = %s AND meta_value <> %s )'
- . ' OR %i.ID IN ( SELECT post_id FROM %i WHERE meta_key = %s AND CAST( meta_value AS SIGNED ) BETWEEN %d AND %d ) )',
+ if ( ! ( $has_fallback[ $field ] ?? false ) ) {
+ $field_clauses[] = $wpdb->prepare(
+ '%i.ID NOT IN ( SELECT post_id FROM %i WHERE meta_key = %s AND meta_value <> %s )',
$wpdb->posts,
$wpdb->postmeta,
$meta_key,
'',
+ );
+ }
+
+ if ( $scores_enabled && isset( self::FIELD_SCORE_META_SUFFIXES[ $field ] ) ) {
+ $field_clauses[] = $wpdb->prepare(
+ '%i.ID IN ( SELECT post_id FROM %i WHERE meta_key = %s AND CAST( meta_value AS SIGNED ) BETWEEN %d AND %d )',
$wpdb->posts,
$wpdb->postmeta,
self::META_PREFIX . self::FIELD_SCORE_META_SUFFIXES[ $field ],
self::NEEDS_IMPROVEMENT_MIN_SCORE,
self::NEEDS_IMPROVEMENT_MAX_SCORE,
);
-
- continue;
}
- $clauses[] = $wpdb->prepare(
- '( %i.ID NOT IN ( SELECT post_id FROM %i WHERE meta_key = %s AND meta_value <> %s ) )',
- $wpdb->posts,
- $wpdb->postmeta,
- $meta_key,
- '',
- );
+ // Always add a clause per field — use a false condition when no real predicate applies so the
+ // field still participates in the outer OR group without incorrectly matching every row.
+ $clauses[] = '( ' . ( ( $field_clauses !== [] ) ? \implode( ' OR ', $field_clauses ) : '0 = 1' ) . ' )';
}
if ( $clauses === [] ) {
diff --git a/src/bulk-editor/user-interface/bulk-editor-integration.php b/src/bulk-editor/user-interface/bulk-editor-integration.php
index dbe481c028d..d4b53baff2c 100644
--- a/src/bulk-editor/user-interface/bulk-editor-integration.php
+++ b/src/bulk-editor/user-interface/bulk-editor-integration.php
@@ -4,6 +4,9 @@
namespace Yoast\WP\SEO\Bulk_Editor\User_Interface;
use WPSEO_Admin_Asset_Manager;
+use WPSEO_Admin_Editor_Specific_Replace_Vars;
+use WPSEO_Admin_Recommended_Replace_Vars;
+use WPSEO_Replace_Vars;
use Yoast\WP\SEO\Bulk_Editor\Application\Content_Types\Content_Types_Repository;
use Yoast\WP\SEO\Bulk_Editor\Application\Endpoints\Endpoints_Repository;
use Yoast\WP\SEO\Bulk_Editor\Domain\Updates\Batch_Limit;
@@ -118,6 +121,13 @@ class Bulk_Editor_Integration implements Integration_Interface {
*/
private $myyoast_connection_data_presenter;
+ /**
+ * The replace vars handler, used to build the replacement variable list for the editor.
+ *
+ * @var WPSEO_Replace_Vars
+ */
+ private $replace_vars;
+
/**
* Constructs the instance.
*
@@ -131,6 +141,7 @@ class Bulk_Editor_Integration implements Integration_Interface {
* @param Options_Helper $options_helper The Options_Helper.
* @param User_Helper $user_helper The User_Helper.
* @param Myyoast_Connection_Data_Presenter $myyoast_connection_data_presenter The MyYoast connection data presenter.
+ * @param WPSEO_Replace_Vars $replace_vars The replace vars handler.
*/
public function __construct(
WPSEO_Admin_Asset_Manager $asset_manager,
@@ -142,7 +153,8 @@ public function __construct(
Endpoints_Repository $endpoints_repository,
Options_Helper $options_helper,
User_Helper $user_helper,
- Myyoast_Connection_Data_Presenter $myyoast_connection_data_presenter
+ Myyoast_Connection_Data_Presenter $myyoast_connection_data_presenter,
+ WPSEO_Replace_Vars $replace_vars
) {
$this->asset_manager = $asset_manager;
$this->current_page_helper = $current_page_helper;
@@ -154,6 +166,7 @@ public function __construct(
$this->options_helper = $options_helper;
$this->user_helper = $user_helper;
$this->myyoast_connection_data_presenter = $myyoast_connection_data_presenter;
+ $this->replace_vars = $replace_vars;
}
/**
@@ -278,12 +291,33 @@ public function get_script_data() {
// prompt content is collected for AI suggestions, which do not depend on the analysis.
'shortcodes' => $this->get_valid_shortcode_tags(),
],
- 'initialSelection' => $this->get_initial_selection( $content_types ),
- 'myyoastConnection' => $this->myyoast_connection_data_presenter->present(),
- // Whether the first-run guided tour has already been seen, so it only shows once per user.
'optInNotificationSeen' => [
'bulk_editor_tour' => $this->is_tour_opt_in_notification_seen(),
],
+ 'initialSelection' => $this->get_initial_selection( $content_types ),
+ 'myyoastConnection' => $this->myyoast_connection_data_presenter->present(),
+ 'replacementVariables' => $this->get_replacement_variables(),
+ ];
+ }
+
+ /**
+ * Builds the replacement variable data passed to the JS editor.
+ *
+ * Mirrors Settings_Integration::get_replacement_variables() so the bulk editor's
+ * ReplacementVariableEditor receives the same variable metadata as the settings page.
+ *
+ * @return array{variables: array>, recommended: array, specific: array, shared: string[]} The replacement variable data.
+ */
+ private function get_replacement_variables(): array {
+ $recommended_replace_vars = new WPSEO_Admin_Recommended_Replace_Vars();
+ $specific_replace_vars = new WPSEO_Admin_Editor_Specific_Replace_Vars();
+ $replacement_variables = $this->replace_vars->get_replacement_variables_with_labels();
+
+ return [
+ 'variables' => $replacement_variables,
+ 'recommended' => $recommended_replace_vars->get_recommended_replacevars(),
+ 'specific' => $specific_replace_vars->get(),
+ 'shared' => $specific_replace_vars->get_generic( $replacement_variables ),
];
}
diff --git a/tests/Unit/Bulk_Editor/Domain/Posts/Post_Test.php b/tests/Unit/Bulk_Editor/Domain/Posts/Post_Test.php
index fc126c474ef..56950cf701a 100644
--- a/tests/Unit/Bulk_Editor/Domain/Posts/Post_Test.php
+++ b/tests/Unit/Bulk_Editor/Domain/Posts/Post_Test.php
@@ -39,21 +39,29 @@ public function test_to_array() {
'social_title' => false,
'social_description' => true,
],
+ '',
+ '',
+ '',
+ '',
);
$this->assertSame(
[
- 'id' => 7,
- 'title' => 'Hello world',
- 'status' => 'draft',
- 'edit_link' => 'post.php?post=7&action=edit',
- 'focus_keyphrase' => 'hello',
- 'seo_title' => 'Hello | Site',
- 'meta_description' => 'A description.',
- 'social_title' => 'Social hello',
- 'social_description' => 'Social description.',
- 'editable' => true,
- 'needs_improvement' => [
+ 'id' => 7,
+ 'title' => 'Hello world',
+ 'status' => 'draft',
+ 'edit_link' => 'post.php?post=7&action=edit',
+ 'focus_keyphrase' => 'hello',
+ 'seo_title' => 'Hello | Site',
+ 'meta_description' => 'A description.',
+ 'social_title' => 'Social hello',
+ 'social_description' => 'Social description.',
+ 'seo_title_fallback' => '',
+ 'meta_description_fallback' => '',
+ 'social_title_fallback' => '',
+ 'social_description_fallback' => '',
+ 'editable' => true,
+ 'needs_improvement' => [
'seo_title' => false,
'meta_description' => true,
'social_title' => false,
@@ -74,17 +82,21 @@ public function test_to_array_not_editable() {
$this->assertSame(
[
- 'id' => 7,
- 'title' => 'Hello world',
- 'status' => 'draft',
- 'edit_link' => '',
- 'focus_keyphrase' => '',
- 'seo_title' => '',
- 'meta_description' => '',
- 'social_title' => '',
- 'social_description' => '',
- 'editable' => false,
- 'needs_improvement' => [
+ 'id' => 7,
+ 'title' => 'Hello world',
+ 'status' => 'draft',
+ 'edit_link' => '',
+ 'focus_keyphrase' => '',
+ 'seo_title' => '',
+ 'meta_description' => '',
+ 'social_title' => '',
+ 'social_description' => '',
+ 'seo_title_fallback' => '',
+ 'meta_description_fallback' => '',
+ 'social_title_fallback' => '',
+ 'social_description_fallback' => '',
+ 'editable' => false,
+ 'needs_improvement' => [
'seo_title' => false,
'meta_description' => false,
'social_title' => false,
diff --git a/tests/Unit/Bulk_Editor/Domain/Posts/Posts_Page_Test.php b/tests/Unit/Bulk_Editor/Domain/Posts/Posts_Page_Test.php
index a6ecc8b82b5..cb1f1e4f3a3 100644
--- a/tests/Unit/Bulk_Editor/Domain/Posts/Posts_Page_Test.php
+++ b/tests/Unit/Bulk_Editor/Domain/Posts/Posts_Page_Test.php
@@ -33,17 +33,21 @@ public function test_to_array() {
[
'posts' => [
[
- 'id' => 7,
- 'title' => 'Hello world',
- 'status' => 'draft',
- 'edit_link' => 'edit',
- 'focus_keyphrase' => 'hello',
- 'seo_title' => 'SEO',
- 'meta_description' => 'Meta',
- 'social_title' => 'OG',
- 'social_description' => 'OG desc',
- 'editable' => true,
- 'needs_improvement' => [
+ 'id' => 7,
+ 'title' => 'Hello world',
+ 'status' => 'draft',
+ 'edit_link' => 'edit',
+ 'focus_keyphrase' => 'hello',
+ 'seo_title' => 'SEO',
+ 'meta_description' => 'Meta',
+ 'social_title' => 'OG',
+ 'social_description' => 'OG desc',
+ 'seo_title_fallback' => '',
+ 'meta_description_fallback' => '',
+ 'social_title_fallback' => '',
+ 'social_description_fallback' => '',
+ 'editable' => true,
+ 'needs_improvement' => [
'seo_title' => false,
'meta_description' => false,
'social_title' => false,
diff --git a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Abstract_Default_Template_Resolver_Test.php b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Abstract_Default_Template_Resolver_Test.php
new file mode 100644
index 00000000000..18e4d461490
--- /dev/null
+++ b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Abstract_Default_Template_Resolver_Test.php
@@ -0,0 +1,44 @@
+options_helper = Mockery::mock( Options_Helper::class );
+ $this->instance = new Default_Template_Resolver( $this->options_helper );
+ }
+}
diff --git a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Resolve_Meta_Description_Test.php b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Resolve_Meta_Description_Test.php
new file mode 100644
index 00000000000..e4a3f41a091
--- /dev/null
+++ b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Resolve_Meta_Description_Test.php
@@ -0,0 +1,56 @@
+options_helper->expects( 'get' )->never();
+
+ $result = $this->instance->resolve_meta_description( 7, 'post', 'My explicit description.' );
+
+ $this->assertSame( 'My explicit description.', $result );
+ }
+
+ /**
+ * Tests that the raw user-configured template is returned when the stored value is empty.
+ *
+ * @return void
+ */
+ public function test_returns_configured_template_when_stored_value_is_empty() {
+ $this->options_helper->expects( 'get' )->with( 'metadesc-post', '' )->andReturn( '%%excerpt%%' );
+
+ $result = $this->instance->resolve_meta_description( 7, 'post', '' );
+
+ $this->assertSame( '%%excerpt%%', $result );
+ }
+
+ /**
+ * Tests that an empty string is returned when no template is configured for the post type.
+ *
+ * Unlike SEO title, meta description has no installation-level default fallback.
+ *
+ * @return void
+ */
+ public function test_returns_empty_when_no_template_is_configured() {
+ $this->options_helper->expects( 'get' )->with( 'metadesc-page', '' )->andReturn( '' );
+
+ $result = $this->instance->resolve_meta_description( 7, 'page', '' );
+
+ $this->assertSame( '', $result );
+ }
+}
diff --git a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Resolve_Seo_Title_Test.php b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Resolve_Seo_Title_Test.php
new file mode 100644
index 00000000000..9285327abb4
--- /dev/null
+++ b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Resolve_Seo_Title_Test.php
@@ -0,0 +1,71 @@
+options_helper->expects( 'get' )->never();
+ $this->options_helper->expects( 'get_title_default' )->never();
+
+ $result = $this->instance->resolve_seo_title( 7, 'post', 'My explicit title' );
+
+ $this->assertSame( 'My explicit title', $result );
+ }
+
+ /**
+ * Tests that the raw user-configured template is returned when the stored value is empty.
+ *
+ * @return void
+ */
+ public function test_returns_configured_template_when_stored_value_is_empty() {
+ $this->options_helper->expects( 'get' )->with( 'title-post', '' )->andReturn( '%%title%% - %%sitename%%' );
+ $this->options_helper->expects( 'get_title_default' )->never();
+
+ $result = $this->instance->resolve_seo_title( 7, 'post', '' );
+
+ $this->assertSame( '%%title%% - %%sitename%%', $result );
+ }
+
+ /**
+ * Tests that the installation default template is returned when the user has not configured one.
+ *
+ * @return void
+ */
+ public function test_returns_default_template_when_configured_template_is_empty() {
+ $this->options_helper->expects( 'get' )->with( 'title-page', '' )->andReturn( '' );
+ $this->options_helper->expects( 'get_title_default' )->with( 'title-page' )->andReturn( '%%title%% %%page%% %%sep%% %%sitename%%' );
+
+ $result = $this->instance->resolve_seo_title( 7, 'page', '' );
+
+ $this->assertSame( '%%title%% %%page%% %%sep%% %%sitename%%', $result );
+ }
+
+ /**
+ * Tests that an empty string is returned when neither a configured template nor an installation default exists.
+ *
+ * @return void
+ */
+ public function test_returns_empty_when_no_template_exists() {
+ $this->options_helper->expects( 'get' )->with( 'title-post', '' )->andReturn( '' );
+ $this->options_helper->expects( 'get_title_default' )->with( 'title-post' )->andReturn( '' );
+
+ $result = $this->instance->resolve_seo_title( 7, 'post', '' );
+
+ $this->assertSame( '', $result );
+ }
+}
diff --git a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Resolve_Social_Description_Test.php b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Resolve_Social_Description_Test.php
new file mode 100644
index 00000000000..0cbc267ddf9
--- /dev/null
+++ b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Resolve_Social_Description_Test.php
@@ -0,0 +1,71 @@
+options_helper->expects( 'get' )->never();
+
+ $result = $this->instance->resolve_social_description( 7, 'post', 'My explicit social description.' );
+
+ $this->assertSame( 'My explicit social description.', $result );
+ }
+
+ /**
+ * Tests that an empty string is returned when OpenGraph is disabled.
+ *
+ * @return void
+ */
+ public function test_returns_empty_when_opengraph_disabled() {
+ $this->options_helper->expects( 'get' )->with( 'opengraph', false )->andReturn( false );
+
+ $result = $this->instance->resolve_social_description( 7, 'post', '' );
+
+ $this->assertSame( '', $result );
+ }
+
+ /**
+ * Tests that the raw user-configured template is returned when the stored value is empty.
+ *
+ * @return void
+ */
+ public function test_returns_configured_template_when_stored_value_is_empty() {
+ $this->options_helper->expects( 'get' )->with( 'opengraph', false )->andReturn( true );
+ $this->options_helper->expects( 'get' )->with( 'social-description-post', '' )->andReturn( '%%excerpt%%' );
+
+ $result = $this->instance->resolve_social_description( 7, 'post', '' );
+
+ $this->assertSame( '%%excerpt%%', $result );
+ }
+
+ /**
+ * Tests that an empty string is returned when no template is configured for the post type.
+ *
+ * Unlike social title, social description has no installation-level default fallback.
+ *
+ * @return void
+ */
+ public function test_returns_empty_when_no_template_is_configured() {
+ $this->options_helper->expects( 'get' )->with( 'opengraph', false )->andReturn( true );
+ $this->options_helper->expects( 'get' )->with( 'social-description-page', '' )->andReturn( '' );
+
+ $result = $this->instance->resolve_social_description( 7, 'page', '' );
+
+ $this->assertSame( '', $result );
+ }
+}
diff --git a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Resolve_Social_Title_Test.php b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Resolve_Social_Title_Test.php
new file mode 100644
index 00000000000..3958e619a35
--- /dev/null
+++ b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Default_Template_Resolver/Resolve_Social_Title_Test.php
@@ -0,0 +1,87 @@
+options_helper->expects( 'get' )->never();
+
+ $result = $this->instance->resolve_social_title( 7, 'post', 'My explicit social title' );
+
+ $this->assertSame( 'My explicit social title', $result );
+ }
+
+ /**
+ * Tests that an empty string is returned when OpenGraph is disabled.
+ *
+ * @return void
+ */
+ public function test_returns_empty_when_opengraph_disabled() {
+ $this->options_helper->expects( 'get' )->with( 'opengraph', false )->andReturn( false );
+ $this->options_helper->expects( 'get_title_default' )->never();
+
+ $result = $this->instance->resolve_social_title( 7, 'post', '' );
+
+ $this->assertSame( '', $result );
+ }
+
+ /**
+ * Tests that the raw user-configured template is returned when the stored value is empty.
+ *
+ * @return void
+ */
+ public function test_returns_configured_template_when_stored_value_is_empty() {
+ $this->options_helper->expects( 'get' )->with( 'opengraph', false )->andReturn( true );
+ $this->options_helper->expects( 'get' )->with( 'social-title-post', '' )->andReturn( '%%title%%' );
+ $this->options_helper->expects( 'get_title_default' )->never();
+
+ $result = $this->instance->resolve_social_title( 7, 'post', '' );
+
+ $this->assertSame( '%%title%%', $result );
+ }
+
+ /**
+ * Tests that the installation default template is returned when the user has not configured one.
+ *
+ * @return void
+ */
+ public function test_returns_default_template_when_configured_template_is_empty() {
+ $this->options_helper->expects( 'get' )->with( 'opengraph', false )->andReturn( true );
+ $this->options_helper->expects( 'get' )->with( 'social-title-page', '' )->andReturn( '' );
+ $this->options_helper->expects( 'get_title_default' )->with( 'social-title-page' )->andReturn( '%%title%%' );
+
+ $result = $this->instance->resolve_social_title( 7, 'page', '' );
+
+ $this->assertSame( '%%title%%', $result );
+ }
+
+ /**
+ * Tests that an empty string is returned when neither a configured template nor an installation default exists.
+ *
+ * @return void
+ */
+ public function test_returns_empty_when_no_template_exists() {
+ $this->options_helper->expects( 'get' )->with( 'opengraph', false )->andReturn( true );
+ $this->options_helper->expects( 'get' )->with( 'social-title-post', '' )->andReturn( '' );
+ $this->options_helper->expects( 'get_title_default' )->with( 'social-title-post' )->andReturn( '' );
+
+ $result = $this->instance->resolve_social_title( 7, 'post', '' );
+
+ $this->assertSame( '', $result );
+ }
+}
diff --git a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Indexable_Posts_Collector/Abstract_Test.php b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Indexable_Posts_Collector/Abstract_Test.php
index 644e0f8a4b9..8f9cae11367 100644
--- a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Indexable_Posts_Collector/Abstract_Test.php
+++ b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Indexable_Posts_Collector/Abstract_Test.php
@@ -5,6 +5,7 @@
namespace Yoast\WP\SEO\Tests\Unit\Bulk_Editor\Infrastructure\Posts\Indexable_Posts_Collector;
use Mockery;
+use Yoast\WP\SEO\Bulk_Editor\Infrastructure\Posts\Default_Template_Resolver;
use Yoast\WP\SEO\Bulk_Editor\Infrastructure\Posts\Indexable_Posts_Collector;
use Yoast\WP\SEO\Bulk_Editor\Infrastructure\Posts\Post_Editability_Resolver;
use Yoast\WP\SEO\Repositories\Indexable_Repository;
@@ -38,6 +39,13 @@ abstract class Abstract_Test extends TestCase {
*/
protected $post_editability_resolver;
+ /**
+ * Holds the default template resolver.
+ *
+ * @var Mockery\MockInterface|Default_Template_Resolver
+ */
+ protected $default_template_resolver;
+
/**
* Sets up the test fixtures.
*
@@ -48,7 +56,18 @@ protected function set_up() {
$this->indexable_repository = Mockery::mock( Indexable_Repository::class );
$this->post_editability_resolver = Mockery::mock( Post_Editability_Resolver::class );
+ $this->default_template_resolver = Mockery::mock( Default_Template_Resolver::class );
+
+ // Pass the stored value through unchanged by default; individual tests override when needed.
+ $this->default_template_resolver->allows( 'resolve_seo_title' )->andReturnArg( 2 )->byDefault();
+ $this->default_template_resolver->allows( 'resolve_meta_description' )->andReturnArg( 2 )->byDefault();
+ $this->default_template_resolver->allows( 'resolve_social_title' )->andReturnArg( 2 )->byDefault();
+ $this->default_template_resolver->allows( 'resolve_social_description' )->andReturnArg( 2 )->byDefault();
- $this->instance = new Indexable_Posts_Collector( $this->indexable_repository, $this->post_editability_resolver );
+ $this->instance = new Indexable_Posts_Collector(
+ $this->indexable_repository,
+ $this->post_editability_resolver,
+ $this->default_template_resolver,
+ );
}
}
diff --git a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Indexable_Posts_Collector/Get_Posts_Test.php b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Indexable_Posts_Collector/Get_Posts_Test.php
index 8604bc21d7e..5e1d96e5a86 100644
--- a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Indexable_Posts_Collector/Get_Posts_Test.php
+++ b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Indexable_Posts_Collector/Get_Posts_Test.php
@@ -21,6 +21,7 @@
* @covers Yoast\WP\SEO\Bulk_Editor\Infrastructure\Posts\Indexable_Posts_Collector::apply_search
* @covers Yoast\WP\SEO\Bulk_Editor\Infrastructure\Posts\Indexable_Posts_Collector::apply_needs_improvement
* @covers Yoast\WP\SEO\Bulk_Editor\Infrastructure\Posts\Indexable_Posts_Collector::build_post
+ * @covers Yoast\WP\SEO\Bulk_Editor\Infrastructure\Posts\Indexable_Posts_Collector::build_needs_improvement
*/
final class Get_Posts_Test extends Abstract_Test {
@@ -62,17 +63,21 @@ public function test_get_posts_editable() {
[
'posts' => [
[
- 'id' => 7,
- 'title' => 'Hello world',
- 'status' => 'draft',
- 'edit_link' => 'post.php?post=7&action=edit',
- 'focus_keyphrase' => 'hello',
- 'seo_title' => 'Hello | Site',
- 'meta_description' => 'A description.',
- 'social_title' => 'Social hello',
- 'social_description' => 'Social description.',
- 'editable' => true,
- 'needs_improvement' => [
+ 'id' => 7,
+ 'title' => 'Hello world',
+ 'status' => 'draft',
+ 'edit_link' => 'post.php?post=7&action=edit',
+ 'focus_keyphrase' => 'hello',
+ 'seo_title' => 'Hello | Site',
+ 'meta_description' => 'A description.',
+ 'social_title' => 'Social hello',
+ 'social_description' => 'Social description.',
+ 'seo_title_fallback' => '',
+ 'meta_description_fallback' => '',
+ 'social_title_fallback' => '',
+ 'social_description_fallback' => '',
+ 'editable' => true,
+ 'needs_improvement' => [
'seo_title' => false,
'meta_description' => true,
'social_title' => false,
@@ -89,6 +94,96 @@ public function test_get_posts_editable() {
);
}
+ /**
+ * Tests that the SEO title and meta description fall back to the resolved template when the stored
+ * values are empty, and that the post is not flagged as needing improvement.
+ *
+ * @return void
+ */
+ public function test_get_posts_resolves_template_when_stored_values_are_empty() {
+ $indexable = new Indexable_Mock();
+ $indexable->object_id = 7;
+ $indexable->object_sub_type = 'page';
+ $indexable->post_status = 'draft';
+ $indexable->primary_focus_keyword = '';
+ $indexable->title = '';
+ $indexable->description = '';
+ $indexable->open_graph_title = '';
+ $indexable->open_graph_description = '';
+ $indexable->seo_title_score = 0;
+ $indexable->meta_description_score = 0;
+
+ $query = $this->stub_page_query( [ $indexable ] );
+ $query->expects( 'count' )->never();
+
+ $this->post_editability_resolver->expects( 'resolve' )->with( [ 7 ] )->andReturn( [ 7 => true ] );
+
+ $this->default_template_resolver->expects( 'resolve_seo_title' )
+ ->with( 7, 'page', '' )
+ ->andReturn( 'Page title from template' );
+ $this->default_template_resolver->expects( 'resolve_meta_description' )
+ ->with( 7, 'page', '' )
+ ->andReturn( 'Page description from template' );
+
+ Functions\expect( 'get_the_title' )->once()->with( 7 )->andReturn( 'A page' );
+ Functions\expect( 'get_edit_post_link' )->once()->with( 7, 'raw' )->andReturn( 'post.php?post=7&action=edit' );
+
+ $result = $this->instance->get_posts( new Posts_Query( 'page', 1, 20, '', self::STATUSES ) )->to_array();
+ $post = $result['posts'][0];
+
+ $this->assertSame( '', $post['seo_title'] );
+ $this->assertSame( '', $post['meta_description'] );
+ $this->assertSame( 'Page title from template', $post['seo_title_fallback'] );
+ $this->assertSame( 'Page description from template', $post['meta_description_fallback'] );
+ $this->assertFalse( $post['needs_improvement']['seo_title'] );
+ $this->assertFalse( $post['needs_improvement']['meta_description'] );
+ }
+
+ /**
+ * Tests that the social title and social description fall back to the raw template when the
+ * stored values are empty, and that the post is not flagged as needing improvement.
+ *
+ * @return void
+ */
+ public function test_get_posts_resolves_social_template_when_stored_values_are_empty() {
+ $indexable = new Indexable_Mock();
+ $indexable->object_id = 7;
+ $indexable->object_sub_type = 'post';
+ $indexable->post_status = 'publish';
+ $indexable->primary_focus_keyword = '';
+ $indexable->title = 'Explicit SEO title';
+ $indexable->description = 'Explicit meta description.';
+ $indexable->open_graph_title = '';
+ $indexable->open_graph_description = '';
+ $indexable->seo_title_score = 0;
+ $indexable->meta_description_score = 0;
+
+ $query = $this->stub_page_query( [ $indexable ] );
+ $query->expects( 'count' )->never();
+
+ $this->post_editability_resolver->expects( 'resolve' )->with( [ 7 ] )->andReturn( [ 7 => true ] );
+
+ $this->default_template_resolver->expects( 'resolve_social_title' )
+ ->with( 7, 'post', '' )
+ ->andReturn( 'Social title from template' );
+ $this->default_template_resolver->expects( 'resolve_social_description' )
+ ->with( 7, 'post', '' )
+ ->andReturn( 'Social description from template' );
+
+ Functions\expect( 'get_the_title' )->once()->with( 7 )->andReturn( 'A post' );
+ Functions\expect( 'get_edit_post_link' )->once()->with( 7, 'raw' )->andReturn( 'post.php?post=7&action=edit' );
+
+ $result = $this->instance->get_posts( new Posts_Query( 'page', 1, 20, '', self::STATUSES ) )->to_array();
+ $post = $result['posts'][0];
+
+ $this->assertSame( '', $post['social_title'] );
+ $this->assertSame( '', $post['social_description'] );
+ $this->assertSame( 'Social title from template', $post['social_title_fallback'] );
+ $this->assertSame( 'Social description from template', $post['social_description_fallback'] );
+ $this->assertFalse( $post['needs_improvement']['social_title'] );
+ $this->assertFalse( $post['needs_improvement']['social_description'] );
+ }
+
/**
* Tests that a non-editable post is returned locked and without its SEO data.
*
@@ -114,17 +209,21 @@ public function test_get_posts_locks_non_editable_post() {
$this->assertSame(
[
- 'id' => 7,
- 'title' => 'Secret post',
- 'status' => 'publish',
- 'edit_link' => '',
- 'focus_keyphrase' => '',
- 'seo_title' => '',
- 'meta_description' => '',
- 'social_title' => '',
- 'social_description' => '',
- 'editable' => false,
- 'needs_improvement' => [
+ 'id' => 7,
+ 'title' => 'Secret post',
+ 'status' => 'publish',
+ 'edit_link' => '',
+ 'focus_keyphrase' => '',
+ 'seo_title' => '',
+ 'meta_description' => '',
+ 'social_title' => '',
+ 'social_description' => '',
+ 'seo_title_fallback' => '',
+ 'meta_description_fallback' => '',
+ 'social_title_fallback' => '',
+ 'social_description_fallback' => '',
+ 'editable' => false,
+ 'needs_improvement' => [
'seo_title' => false,
'meta_description' => false,
'social_title' => false,
@@ -286,6 +385,111 @@ public function test_get_posts_restricts_to_the_included_post_ids() {
$this->assertSame( 1, $result['total'] );
}
+ /**
+ * Tests that a post with an empty stored SEO title is flagged as needing improvement when no
+ * post-type template is configured.
+ *
+ * @return void
+ */
+ public function test_get_posts_flags_seo_title_when_no_template_configured_and_stored_value_is_empty() {
+ $indexable = new Indexable_Mock();
+ $indexable->object_id = 7;
+ $indexable->object_sub_type = 'page';
+ $indexable->post_status = 'draft';
+ $indexable->primary_focus_keyword = '';
+ $indexable->title = ''; // No stored value.
+ $indexable->description = 'Explicit description.';
+ $indexable->open_graph_title = '';
+ $indexable->open_graph_description = '';
+ $indexable->seo_title_score = 0;
+ $indexable->meta_description_score = 0;
+
+ $query = $this->stub_page_query( [ $indexable ] );
+ $query->expects( 'count' )->never();
+
+ $this->post_editability_resolver->expects( 'resolve' )->with( [ 7 ] )->andReturn( [ 7 => true ] );
+
+ Functions\expect( 'get_the_title' )->once()->with( 7 )->andReturn( 'A page' );
+ Functions\expect( 'get_edit_post_link' )->once()->with( 7, 'raw' )->andReturn( 'post.php?post=7&action=edit' );
+
+ $result = $this->instance->get_posts( new Posts_Query( 'page', 1, 20, '', self::STATUSES ) )->to_array();
+ $post = $result['posts'][0];
+
+ // No template configured: the resolver returns '' → the flag must be true.
+ $this->assertTrue( $post['needs_improvement']['seo_title'] );
+ // Stored description is non-empty, so meta description does not need improvement.
+ $this->assertFalse( $post['needs_improvement']['meta_description'] );
+ }
+
+ /**
+ * Tests that the needs-improvement SQL filter and the per-row flag agree when the post type has a
+ * configured SEO title template.
+ *
+ * The SQL filter must exclude template-defaulted posts (1 = 0, not IS NULL) and the row flag must
+ * report them as not needing improvement. A mismatch between the two caused the bug in #23438.
+ *
+ * @return void
+ */
+ public function test_filter_and_row_flag_agree_when_post_type_has_seo_title_template() {
+ $indexable = new Indexable_Mock();
+ $indexable->object_id = 7;
+ $indexable->object_sub_type = 'page';
+ $indexable->post_status = 'draft';
+ $indexable->primary_focus_keyword = '';
+ $indexable->title = ''; // No stored value; fallback template applies.
+ $indexable->description = '';
+ $indexable->open_graph_title = '';
+ $indexable->open_graph_description = '';
+ $indexable->seo_title_score = 0;
+ $indexable->meta_description_score = 0;
+
+ // Resolver returns a template for seo_title when called with any post_id, 'page', ''.
+ $this->default_template_resolver->allows( 'resolve_seo_title' )
+ ->with( Mockery::any(), 'page', '' )
+ ->andReturn( '%%title%% %%sep%% %%sitename%%' );
+
+ $captured = [];
+ $query = Mockery::mock( ORM::class );
+ $query->allows( 'where' )->andReturnSelf();
+ $query->allows( 'where_in' )->andReturnSelf();
+ $query->allows( 'order_by_desc' )->andReturnSelf();
+ $query->allows( 'limit' )->andReturnSelf();
+ $query->allows( 'offset' )->andReturnSelf();
+ // Non-full page (1 row < per_page 20): resolve_total skips the count query, so where_raw fires once.
+ $query->expects( 'where_raw' )
+ ->once()
+ ->with(
+ Mockery::on(
+ static function ( $clause ) use ( &$captured ) {
+ $captured[] = $clause;
+
+ return true;
+ },
+ ),
+ [], // Template configured, scoring disabled — no bound values.
+ )
+ ->andReturnSelf();
+ $query->expects( 'find_many' )->once()->andReturn( [ $indexable ] );
+
+ $this->indexable_repository->allows( 'query' )->andReturn( $query );
+ $this->post_editability_resolver->expects( 'resolve' )->with( [ 7 ] )->andReturn( [ 7 => true ] );
+
+ Functions\expect( 'get_the_title' )->once()->with( 7 )->andReturn( 'A page' );
+ Functions\expect( 'get_edit_post_link' )->once()->with( 7, 'raw' )->andReturn( 'post.php?post=7&action=edit' );
+
+ $result = $this->instance->get_posts(
+ new Posts_Query( 'page', 1, 20, '', self::STATUSES, null, [ 'seo_title' ], false ),
+ )->to_array();
+ $post = $result['posts'][0];
+
+ // SQL: the filter must use the false condition, not an empty-column check.
+ $this->assertStringContainsString( '1 = 0', $captured[0] );
+ $this->assertStringNotContainsString( 'title IS NULL', $captured[0] );
+
+ // Row flag: the post is not needing improvement — the template covers the gap.
+ $this->assertFalse( $post['needs_improvement']['seo_title'] );
+ }
+
/**
* Stubs the indexable query for a page that returns the given rows, without constraining count().
*
diff --git a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Abstract_Test.php b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Abstract_Test.php
index 4841c10e913..b3c358a45f7 100644
--- a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Abstract_Test.php
+++ b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Abstract_Test.php
@@ -5,6 +5,7 @@
namespace Yoast\WP\SEO\Tests\Unit\Bulk_Editor\Infrastructure\Posts\Post_Meta_Posts_Collector;
use Mockery;
+use Yoast\WP\SEO\Bulk_Editor\Infrastructure\Posts\Default_Template_Resolver;
use Yoast\WP\SEO\Bulk_Editor\Infrastructure\Posts\Post_Editability_Resolver;
use Yoast\WP\SEO\Bulk_Editor\Infrastructure\Posts\Post_Meta_Posts_Collector;
use Yoast\WP\SEO\Tests\Unit\TestCase;
@@ -23,6 +24,13 @@ abstract class Abstract_Test extends TestCase {
*/
protected $post_editability_resolver;
+ /**
+ * Holds the default template resolver.
+ *
+ * @var Mockery\MockInterface|Default_Template_Resolver
+ */
+ protected $default_template_resolver;
+
/**
* Holds the instance.
*
@@ -41,8 +49,18 @@ protected function set_up() {
parent::set_up();
$this->post_editability_resolver = Mockery::mock( Post_Editability_Resolver::class );
+ $this->default_template_resolver = Mockery::mock( Default_Template_Resolver::class );
+
+ // Pass the stored value through unchanged by default; individual tests override when needed.
+ $this->default_template_resolver->allows( 'resolve_seo_title' )->andReturnArg( 2 )->byDefault();
+ $this->default_template_resolver->allows( 'resolve_meta_description' )->andReturnArg( 2 )->byDefault();
+ $this->default_template_resolver->allows( 'resolve_social_title' )->andReturnArg( 2 )->byDefault();
+ $this->default_template_resolver->allows( 'resolve_social_description' )->andReturnArg( 2 )->byDefault();
- $this->instance = Mockery::mock( Post_Meta_Posts_Collector::class, [ $this->post_editability_resolver ] )
+ $this->instance = Mockery::mock(
+ Post_Meta_Posts_Collector::class,
+ [ $this->post_editability_resolver, $this->default_template_resolver ],
+ )
->makePartial()
->shouldAllowMockingProtectedMethods();
}
diff --git a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Build_Needs_Improvement_Where_Test.php b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Build_Needs_Improvement_Where_Test.php
index e6861a0fca5..13647c24dbd 100644
--- a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Build_Needs_Improvement_Where_Test.php
+++ b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Build_Needs_Improvement_Where_Test.php
@@ -5,6 +5,8 @@
namespace Yoast\WP\SEO\Tests\Unit\Bulk_Editor\Infrastructure\Posts\Post_Meta_Posts_Collector;
use Mockery;
+use Mockery\MockInterface;
+use Yoast\WP\SEO\Bulk_Editor\Infrastructure\Posts\Default_Template_Resolver;
use Yoast\WP\SEO\Bulk_Editor\Infrastructure\Posts\Post_Editability_Resolver;
use Yoast\WP\SEO\Tests\Unit\Doubles\Bulk_Editor\Post_Meta_Posts_Collector_Double;
use Yoast\WP\SEO\Tests\Unit\TestCase;
@@ -25,6 +27,13 @@ final class Build_Needs_Improvement_Where_Test extends TestCase {
*/
private $instance;
+ /**
+ * The default template resolver mock.
+ *
+ * @var Default_Template_Resolver&MockInterface
+ */
+ private $default_template_resolver;
+
/**
* Sets up the test fixtures.
*
@@ -42,7 +51,18 @@ protected function set_up() {
$wpdb->postmeta = 'wp_postmeta';
$wpdb->allows( 'prepare' )->andReturnUsing( [ $this, 'interpolate_query' ] );
- $this->instance = new Post_Meta_Posts_Collector_Double( Mockery::mock( Post_Editability_Resolver::class ) );
+ $this->default_template_resolver = Mockery::mock( Default_Template_Resolver::class );
+
+ // Return empty string by default — no fallback template configured — so the empty-value clause is kept.
+ $this->default_template_resolver->allows( 'resolve_seo_title' )->andReturn( '' )->byDefault();
+ $this->default_template_resolver->allows( 'resolve_meta_description' )->andReturn( '' )->byDefault();
+ $this->default_template_resolver->allows( 'resolve_social_title' )->andReturn( '' )->byDefault();
+ $this->default_template_resolver->allows( 'resolve_social_description' )->andReturn( '' )->byDefault();
+
+ $this->instance = new Post_Meta_Posts_Collector_Double(
+ Mockery::mock( Post_Editability_Resolver::class ),
+ $this->default_template_resolver,
+ );
}
/**
@@ -147,4 +167,41 @@ public function test_returns_empty_string_for_no_known_fields() {
$this->assertSame( '', $this->instance->expose_build_needs_improvement_where( [], true ) );
$this->assertSame( '', $this->instance->expose_build_needs_improvement_where( [ 'unknown_field' ], true ) );
}
+
+ /**
+ * Tests that a field with a post-type fallback template is excluded from the empty-value clause.
+ *
+ * When the post type has a configured template for a field, template-defaulted posts are not genuinely
+ * empty and should not appear in the "needs improvement" filter. The empty-value clause is replaced
+ * with a false condition so those posts are excluded while the OR-group structure is preserved.
+ *
+ * @return void
+ */
+ public function test_skips_empty_clause_when_post_type_has_fallback_template() {
+ $this->default_template_resolver->allows( 'resolve_seo_title' )
+ ->with( 0, 'post', '' )
+ ->andReturn( '%%title%% %%sep%% %%sitename%%' );
+
+ $where = $this->instance->expose_build_needs_improvement_where( [ 'seo_title' ], false, 'post' );
+
+ $this->assertStringNotContainsString( '_yoast_wpseo_title', $where );
+ $this->assertStringContainsString( '0 = 1', $where );
+ }
+
+ /**
+ * Tests that only the score clause is kept for a field with a template when scoring is enabled.
+ *
+ * @return void
+ */
+ public function test_keeps_score_clause_for_templated_field_when_scoring_enabled() {
+ $this->default_template_resolver->allows( 'resolve_seo_title' )
+ ->with( 0, 'post', '' )
+ ->andReturn( '%%title%% %%sep%% %%sitename%%' );
+
+ $where = $this->instance->expose_build_needs_improvement_where( [ 'seo_title' ], true, 'post' );
+
+ $this->assertStringNotContainsString( "meta_key = '_yoast_wpseo_title' AND meta_value <> ''", $where );
+ $this->assertStringContainsString( '_yoast_wpseo_seo_title_score', $where );
+ $this->assertStringContainsString( 'BETWEEN', $where );
+ }
}
diff --git a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Build_Query_Args_Test.php b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Build_Query_Args_Test.php
index 52f392275c9..ecef0cb745d 100644
--- a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Build_Query_Args_Test.php
+++ b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Build_Query_Args_Test.php
@@ -4,8 +4,10 @@
// phpcs:disable Yoast.NamingConventions.NamespaceName.MaxExceeded
namespace Yoast\WP\SEO\Tests\Unit\Bulk_Editor\Infrastructure\Posts\Post_Meta_Posts_Collector;
+use Mockery;
use ReflectionMethod;
use Yoast\WP\SEO\Bulk_Editor\Domain\Posts\Posts_Query;
+use Yoast\WP\SEO\Bulk_Editor\Infrastructure\Posts\Default_Template_Resolver;
use Yoast\WP\SEO\Bulk_Editor\Infrastructure\Posts\Post_Meta_Posts_Collector;
/**
@@ -58,7 +60,7 @@ public function test_build_query_args_without_included_post_ids() {
* @return array|array> The built WP_Query arguments.
*/
private function invoke_build_query_args( Posts_Query $query ): array {
- $instance = new Post_Meta_Posts_Collector( $this->post_editability_resolver );
+ $instance = new Post_Meta_Posts_Collector( $this->post_editability_resolver, Mockery::mock( Default_Template_Resolver::class ) );
$reflection = new ReflectionMethod( $instance, 'build_query_args' );
$reflection->setAccessible( true );
diff --git a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Get_Posts_Test.php b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Get_Posts_Test.php
index 05b347e712b..239e266ce46 100644
--- a/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Get_Posts_Test.php
+++ b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Get_Posts_Test.php
@@ -15,6 +15,7 @@
* @group bulk-editor
*
* @covers Yoast\WP\SEO\Bulk_Editor\Infrastructure\Posts\Post_Meta_Posts_Collector::get_posts
+ * @covers Yoast\WP\SEO\Bulk_Editor\Infrastructure\Posts\Post_Meta_Posts_Collector::build_needs_improvement
*/
final class Get_Posts_Test extends Abstract_Test {
@@ -45,7 +46,12 @@ public function test_get_posts_editable() {
$this->post_editability_resolver->expects( 'resolve' )->with( [ 7 ] )->andReturn( [ 7 => true ] );
- Functions\expect( 'get_post' )->once()->with( 7 )->andReturn( (object) [ 'post_status' => 'draft' ] );
+ Functions\expect( 'get_post' )->once()->with( 7 )->andReturn(
+ (object) [
+ 'post_status' => 'draft',
+ 'post_type' => 'post',
+ ],
+ );
Functions\expect( 'get_the_title' )->once()->with( 7 )->andReturn( 'Hello world' );
Functions\expect( 'get_edit_post_link' )->once()->with( 7, 'raw' )->andReturn( 'post.php?post=7&action=edit' );
Functions\expect( 'get_post_meta' )
@@ -60,17 +66,21 @@ static function ( $post_id, $key ) use ( $meta ) {
[
'posts' => [
[
- 'id' => 7,
- 'title' => 'Hello world',
- 'status' => 'draft',
- 'edit_link' => 'post.php?post=7&action=edit',
- 'focus_keyphrase' => 'hello',
- 'seo_title' => 'Hello | Site',
- 'meta_description' => 'A description.',
- 'social_title' => 'Social hello',
- 'social_description' => 'Social description.',
- 'editable' => true,
- 'needs_improvement' => [
+ 'id' => 7,
+ 'title' => 'Hello world',
+ 'status' => 'draft',
+ 'edit_link' => 'post.php?post=7&action=edit',
+ 'focus_keyphrase' => 'hello',
+ 'seo_title' => 'Hello | Site',
+ 'meta_description' => 'A description.',
+ 'social_title' => 'Social hello',
+ 'social_description' => 'Social description.',
+ 'seo_title_fallback' => '',
+ 'meta_description_fallback' => '',
+ 'social_title_fallback' => '',
+ 'social_description_fallback' => '',
+ 'editable' => true,
+ 'needs_improvement' => [
'seo_title' => false,
'meta_description' => true,
'social_title' => false,
@@ -97,7 +107,12 @@ public function test_get_posts_locks_non_editable_post() {
$this->post_editability_resolver->expects( 'resolve' )->with( [ 7 ] )->andReturn( [ 7 => false ] );
- Functions\expect( 'get_post' )->once()->with( 7 )->andReturn( (object) [ 'post_status' => 'publish' ] );
+ Functions\expect( 'get_post' )->once()->with( 7 )->andReturn(
+ (object) [
+ 'post_status' => 'publish',
+ 'post_type' => 'post',
+ ],
+ );
Functions\expect( 'get_the_title' )->once()->with( 7 )->andReturn( 'Secret post' );
// A locked post exposes neither its edit link nor its Yoast meta.
Functions\expect( 'get_edit_post_link' )->never();
@@ -107,17 +122,21 @@ public function test_get_posts_locks_non_editable_post() {
$this->assertSame(
[
- 'id' => 7,
- 'title' => 'Secret post',
- 'status' => 'publish',
- 'edit_link' => '',
- 'focus_keyphrase' => '',
- 'seo_title' => '',
- 'meta_description' => '',
- 'social_title' => '',
- 'social_description' => '',
- 'editable' => false,
- 'needs_improvement' => [
+ 'id' => 7,
+ 'title' => 'Secret post',
+ 'status' => 'publish',
+ 'edit_link' => '',
+ 'focus_keyphrase' => '',
+ 'seo_title' => '',
+ 'meta_description' => '',
+ 'social_title' => '',
+ 'social_description' => '',
+ 'seo_title_fallback' => '',
+ 'meta_description_fallback' => '',
+ 'social_title_fallback' => '',
+ 'social_description_fallback' => '',
+ 'editable' => false,
+ 'needs_improvement' => [
'seo_title' => false,
'meta_description' => false,
'social_title' => false,
@@ -138,7 +157,12 @@ public function test_get_posts_reports_total_from_found_posts() {
$this->post_editability_resolver->expects( 'resolve' )->with( [ 7 ] )->andReturn( [ 7 => true ] );
- Functions\expect( 'get_post' )->once()->andReturn( (object) [ 'post_status' => 'draft' ] );
+ Functions\expect( 'get_post' )->once()->andReturn(
+ (object) [
+ 'post_status' => 'draft',
+ 'post_type' => 'post',
+ ],
+ );
Functions\expect( 'get_the_title' )->once()->andReturn( 'Hello world' );
Functions\expect( 'get_edit_post_link' )->once()->andReturn( 'edit' );
Functions\expect( 'get_post_meta' )->times( 7 )->andReturn( '' );
@@ -149,6 +173,144 @@ public function test_get_posts_reports_total_from_found_posts() {
$this->assertSame( 3, $result['total_pages'] );
}
+ /**
+ * Tests that the SEO title and meta description fall back to the resolved template when the stored
+ * values are empty, and that the post is not flagged as needing improvement.
+ *
+ * @return void
+ */
+ public function test_get_posts_resolves_template_when_stored_values_are_empty() {
+ $meta = [
+ '_yoast_wpseo_focuskw' => '',
+ '_yoast_wpseo_title' => '',
+ '_yoast_wpseo_metadesc' => '',
+ '_yoast_wpseo_opengraph-title' => 'Social hello',
+ '_yoast_wpseo_opengraph-description' => 'Social description.',
+ '_yoast_wpseo_seo_title_score' => '0',
+ '_yoast_wpseo_meta_description_score' => '0',
+ ];
+
+ $this->stub_run_query( [ 7 ], 1 );
+
+ $this->post_editability_resolver->expects( 'resolve' )->with( [ 7 ] )->andReturn( [ 7 => true ] );
+
+ Functions\expect( 'get_post' )->once()->with( 7 )->andReturn(
+ (object) [
+ 'post_status' => 'draft',
+ 'post_type' => 'page',
+ ],
+ );
+ Functions\expect( 'get_the_title' )->once()->with( 7 )->andReturn( 'A page' );
+ Functions\expect( 'get_edit_post_link' )->once()->with( 7, 'raw' )->andReturn( 'post.php?post=7&action=edit' );
+ Functions\expect( 'get_post_meta' )
+ ->times( 7 )
+ ->andReturnUsing(
+ static function ( $post_id, $key ) use ( $meta ) {
+ return $meta[ $key ];
+ },
+ );
+
+ $this->default_template_resolver->expects( 'resolve_seo_title' )
+ ->with( 7, 'page', '' )
+ ->andReturn( 'Page title from template' );
+ $this->default_template_resolver->expects( 'resolve_meta_description' )
+ ->with( 7, 'page', '' )
+ ->andReturn( 'Page description from template' );
+
+ $result = $this->instance->get_posts( new Posts_Query( 'page', 1, 20, '', self::STATUSES ) )->to_array();
+ $post = $result['posts'][0];
+
+ $this->assertSame( '', $post['seo_title'] );
+ $this->assertSame( '', $post['meta_description'] );
+ $this->assertSame( 'Page title from template', $post['seo_title_fallback'] );
+ $this->assertSame( 'Page description from template', $post['meta_description_fallback'] );
+ $this->assertFalse( $post['needs_improvement']['seo_title'] );
+ $this->assertFalse( $post['needs_improvement']['meta_description'] );
+ }
+
+ /**
+ * Tests that the social title and social description fall back to the raw template when the
+ * stored values are empty, and that the post is not flagged as needing improvement.
+ *
+ * @return void
+ */
+ public function test_get_posts_resolves_social_template_when_stored_values_are_empty() {
+ $meta = [
+ '_yoast_wpseo_focuskw' => '',
+ '_yoast_wpseo_title' => 'Explicit SEO title',
+ '_yoast_wpseo_metadesc' => 'Explicit meta description.',
+ '_yoast_wpseo_opengraph-title' => '',
+ '_yoast_wpseo_opengraph-description' => '',
+ '_yoast_wpseo_seo_title_score' => '0',
+ '_yoast_wpseo_meta_description_score' => '0',
+ ];
+
+ $this->stub_run_query( [ 7 ], 1 );
+
+ $this->post_editability_resolver->expects( 'resolve' )->with( [ 7 ] )->andReturn( [ 7 => true ] );
+
+ Functions\expect( 'get_post' )->once()->with( 7 )->andReturn(
+ (object) [
+ 'post_status' => 'publish',
+ 'post_type' => 'post',
+ ],
+ );
+ Functions\expect( 'get_the_title' )->once()->with( 7 )->andReturn( 'A post' );
+ Functions\expect( 'get_edit_post_link' )->once()->with( 7, 'raw' )->andReturn( 'post.php?post=7&action=edit' );
+ Functions\expect( 'get_post_meta' )
+ ->times( 7 )
+ ->andReturnUsing(
+ static function ( $post_id, $key ) use ( $meta ) {
+ return $meta[ $key ];
+ },
+ );
+
+ $this->default_template_resolver->expects( 'resolve_social_title' )
+ ->with( 7, 'post', '' )
+ ->andReturn( 'Social title from template' );
+ $this->default_template_resolver->expects( 'resolve_social_description' )
+ ->with( 7, 'post', '' )
+ ->andReturn( 'Social description from template' );
+
+ $result = $this->instance->get_posts( new Posts_Query( 'page', 1, 20, '', self::STATUSES ) )->to_array();
+ $post = $result['posts'][0];
+
+ $this->assertSame( '', $post['social_title'] );
+ $this->assertSame( '', $post['social_description'] );
+ $this->assertSame( 'Social title from template', $post['social_title_fallback'] );
+ $this->assertSame( 'Social description from template', $post['social_description_fallback'] );
+ $this->assertFalse( $post['needs_improvement']['social_title'] );
+ $this->assertFalse( $post['needs_improvement']['social_description'] );
+ }
+
+ /**
+ * Tests that the collector handles a null get_post() return gracefully when the post is editable.
+ *
+ * The get_post() function returns null when the post has been deleted between the WP_Query and the per-row fetch.
+ * In that case post_type falls back to '' and the resolver is called with an empty post type; the
+ * collector must not crash and must return a post with empty status.
+ *
+ * @return void
+ */
+ public function test_get_posts_handles_null_get_post_gracefully() {
+ $this->stub_run_query( [ 7 ], 1 );
+
+ $this->post_editability_resolver->expects( 'resolve' )->with( [ 7 ] )->andReturn( [ 7 => true ] );
+
+ Functions\expect( 'get_post' )->once()->with( 7 )->andReturnNull();
+ Functions\expect( 'get_the_title' )->once()->with( 7 )->andReturn( 'A page' );
+ Functions\expect( 'get_edit_post_link' )->once()->with( 7, 'raw' )->andReturn( 'post.php?post=7&action=edit' );
+ Functions\expect( 'get_post_meta' )->times( 7 )->andReturn( '' );
+
+ $result = $this->instance->get_posts( new Posts_Query( 'page', 1, 20, '', self::STATUSES ) )->to_array();
+ $post = $result['posts'][0];
+
+ // Status is empty because get_post() returned null.
+ $this->assertSame( '', $post['status'] );
+ $this->assertSame( 7, $post['id'] );
+ $this->assertTrue( $post['editable'] );
+ }
+
/**
* Stubs run_query so it returns a WP_Query with the given post IDs and total.
*
diff --git a/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Abstract_Test.php b/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Abstract_Test.php
index 42889bb28bb..0bdfa8998f1 100644
--- a/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Abstract_Test.php
+++ b/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Abstract_Test.php
@@ -4,8 +4,10 @@
// phpcs:disable Yoast.NamingConventions.NamespaceName.MaxExceeded
namespace Yoast\WP\SEO\Tests\Unit\Bulk_Editor\User_Interface\Bulk_Editor_Integration;
+use Brain\Monkey\Functions;
use Mockery;
use WPSEO_Admin_Asset_Manager;
+use WPSEO_Replace_Vars;
use Yoast\WP\SEO\Bulk_Editor\Application\Content_Types\Content_Types_Repository;
use Yoast\WP\SEO\Bulk_Editor\Application\Endpoints\Endpoints_Repository;
use Yoast\WP\SEO\Bulk_Editor\Infrastructure\Nonces\Nonce_Repository;
@@ -102,6 +104,30 @@ abstract class Abstract_Test extends TestCase {
*/
protected $myyoast_connection_data_presenter;
+ /**
+ * Holds the WPSEO_Replace_Vars mock.
+ *
+ * @var Mockery\MockInterface|WPSEO_Replace_Vars
+ */
+ protected $replace_vars;
+
+ /**
+ * Stubs the WP globals and functions consumed by WPSEO_Admin_Editor_Specific_Replace_Vars::__construct().
+ *
+ * Must be called before any test that exercises get_script_data() / enqueue_assets().
+ *
+ * @return void
+ */
+ protected function stub_wpseo_admin_replace_vars_dependencies(): void {
+ global $wpdb;
+ $wpdb = Mockery::mock();
+ $wpdb->postmeta = 'wp_postmeta';
+ $wpdb->allows( 'prepare' )->andReturn( '' );
+ $wpdb->allows( 'get_col' )->andReturn( [] );
+
+ Functions\stubs( [ 'get_taxonomies' => [] ] );
+ }
+
/**
* Sets up the test fixtures.
*
@@ -120,6 +146,7 @@ protected function set_up() {
$this->options_helper = Mockery::mock( Options_Helper::class );
$this->user_helper = Mockery::mock( User_Helper::class );
$this->myyoast_connection_data_presenter = Mockery::mock( Myyoast_Connection_Data_Presenter::class );
+ $this->replace_vars = Mockery::mock( WPSEO_Replace_Vars::class );
$this->instance = new Bulk_Editor_Integration(
$this->asset_manager,
@@ -132,6 +159,7 @@ protected function set_up() {
$this->options_helper,
$this->user_helper,
$this->myyoast_connection_data_presenter,
+ $this->replace_vars,
);
}
}
diff --git a/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Constructor_Test.php b/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Constructor_Test.php
index 0cd92a81b5c..d4257558052 100644
--- a/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Constructor_Test.php
+++ b/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Constructor_Test.php
@@ -5,6 +5,7 @@
namespace Yoast\WP\SEO\Tests\Unit\Bulk_Editor\User_Interface\Bulk_Editor_Integration;
use WPSEO_Admin_Asset_Manager;
+use WPSEO_Replace_Vars;
use Yoast\WP\SEO\Bulk_Editor\Application\Content_Types\Content_Types_Repository;
use Yoast\WP\SEO\Bulk_Editor\Application\Endpoints\Endpoints_Repository;
use Yoast\WP\SEO\Bulk_Editor\Infrastructure\Nonces\Nonce_Repository;
@@ -70,5 +71,9 @@ public function test_constructor() {
Myyoast_Connection_Data_Presenter::class,
$this->getPropertyValue( $this->instance, 'myyoast_connection_data_presenter' ),
);
+ $this->assertInstanceOf(
+ WPSEO_Replace_Vars::class,
+ $this->getPropertyValue( $this->instance, 'replace_vars' ),
+ );
}
}
diff --git a/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Enqueue_Assets_Test.php b/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Enqueue_Assets_Test.php
index 5efbe11baa5..76e46f3bade 100644
--- a/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Enqueue_Assets_Test.php
+++ b/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Enqueue_Assets_Test.php
@@ -74,6 +74,7 @@ protected function tear_down() {
*/
public function test_enqueue_assets( $shortcode_tags, array $expected_shortcodes ) {
$this->stubEscapeFunctions();
+ $this->stub_wpseo_admin_replace_vars_dependencies();
// The registered shortcode tags are read straight off the WordPress global.
unset( $GLOBALS['shortcode_tags'] );
@@ -89,40 +90,6 @@ public function test_enqueue_assets( $shortcode_tags, array $expected_shortcodes
],
];
- $expected_script_data = [
- 'contentTypes' => $content_types,
- 'endpoints' => [
- 'posts' => 'https://example.com/wp-json/yoast/v1/bulk_editor/posts',
- ],
- 'links' => [
- 'dashboard' => 'https://example.com/wp-admin/admin.php?page=wpseo_dashboard',
- 'tools' => 'https://example.com/wp-admin/admin.php?page=wpseo_tools',
- ],
- 'nonce' => 'rest-nonce',
- 'restRoot' => 'https://example.com/wp-json/',
- 'preferences' => [
- 'isPremium' => false,
- 'isAiEnabled' => true,
- 'isRtl' => false,
- 'pluginUrl' => 'https://example.com/wp-content/plugins/wordpress-seo',
- ],
- 'linkParams' => [ 'foo' => 'bar' ],
- 'analysis' => [
- 'contentLocale' => 'en_US',
- 'keywordAnalysisActive' => true,
- 'shortcodes' => $expected_shortcodes,
- ],
- 'initialSelection' => [
- 'contentType' => '',
- 'postIds' => [],
- 'selectedCount' => 0,
- ],
- 'myyoastConnection' => null,
- 'optInNotificationSeen' => [
- 'bulk_editor_tour' => false,
- ],
- ];
-
Actions\expectRemoved( 'admin_print_scripts' )->once()->with( 'print_emoji_detection_script' );
$this->asset_manager->expects( 'enqueue_script' )->once()->with( Bulk_Editor_Integration::ASSETS_NAME );
@@ -159,10 +126,27 @@ static function ( $path ) {
->once()
->with( 1, '_yoast_wpseo_bulk_editor_tour_opt_in_notification_seen', true )
->andReturn( '' );
+ $this->replace_vars->expects( 'get_replacement_variables_with_labels' )->once()->andReturn( [] );
$this->asset_manager->expects( 'localize_script' )
->once()
- ->with( Bulk_Editor_Integration::ASSETS_NAME, 'wpseoBulkEditorData', $expected_script_data );
+ ->with(
+ Bulk_Editor_Integration::ASSETS_NAME,
+ 'wpseoBulkEditorData',
+ Mockery::on(
+ static function ( $data ) use ( $content_types, $expected_shortcodes ) {
+ return $data['contentTypes'] === $content_types
+ && $data['nonce'] === 'rest-nonce'
+ && $data['preferences']['isPremium'] === false
+ && $data['analysis']['shortcodes'] === $expected_shortcodes
+ && \array_key_exists( 'replacementVariables', $data )
+ && \array_key_exists( 'variables', $data['replacementVariables'] )
+ && \array_key_exists( 'recommended', $data['replacementVariables'] )
+ && \array_key_exists( 'specific', $data['replacementVariables'] )
+ && \array_key_exists( 'shared', $data['replacementVariables'] );
+ },
+ ),
+ );
$this->instance->enqueue_assets();
}
diff --git a/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Get_Initial_Selection_Test.php b/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Get_Initial_Selection_Test.php
index 63cfcf357e2..c8ee48f52b3 100644
--- a/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Get_Initial_Selection_Test.php
+++ b/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Get_Initial_Selection_Test.php
@@ -172,6 +172,8 @@ public function test_ignores_a_non_scalar_selected_count() {
* @return array> The initial selection script data.
*/
private function get_initial_selection() {
+ $this->stub_wpseo_admin_replace_vars_dependencies();
+ $this->replace_vars->allows( 'get_replacement_variables_with_labels' )->andReturn( [] );
$this->stubEscapeFunctions();
Functions\stubs(
[
diff --git a/tests/Unit/Doubles/Bulk_Editor/Post_Meta_Posts_Collector_Double.php b/tests/Unit/Doubles/Bulk_Editor/Post_Meta_Posts_Collector_Double.php
index 620854f8839..c3e62a7052a 100644
--- a/tests/Unit/Doubles/Bulk_Editor/Post_Meta_Posts_Collector_Double.php
+++ b/tests/Unit/Doubles/Bulk_Editor/Post_Meta_Posts_Collector_Double.php
@@ -17,7 +17,7 @@ class Post_Meta_Posts_Collector_Double extends Post_Meta_Posts_Collector {
*
* @return string The prepared WHERE clause.
*/
- public function expose_build_needs_improvement_where( array $fields, bool $scores_enabled ): string {
- return $this->build_needs_improvement_where( $fields, $scores_enabled );
+ public function expose_build_needs_improvement_where( array $fields, bool $scores_enabled, string $post_type = '' ): string {
+ return $this->build_needs_improvement_where( $fields, $scores_enabled, $post_type );
}
}
diff --git a/tests/WP/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Myyoast_Connection_Data_Test.php b/tests/WP/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Myyoast_Connection_Data_Test.php
index d670c3aa28e..9d38f58ce1a 100644
--- a/tests/WP/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Myyoast_Connection_Data_Test.php
+++ b/tests/WP/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Myyoast_Connection_Data_Test.php
@@ -6,6 +6,7 @@
use Mockery;
use WPSEO_Admin_Asset_Manager;
+use WPSEO_Replace_Vars;
use Yoast\WP\SEO\Bulk_Editor\Application\Content_Types\Content_Types_Repository;
use Yoast\WP\SEO\Bulk_Editor\Application\Endpoints\Endpoints_Repository;
use Yoast\WP\SEO\Bulk_Editor\Infrastructure\Nonces\Nonce_Repository;
@@ -72,6 +73,13 @@ final class Myyoast_Connection_Data_Test extends TestCase {
*/
private $user_helper;
+ /**
+ * The replacement-variable helper mock.
+ *
+ * @var Mockery\MockInterface|WPSEO_Replace_Vars
+ */
+ private $replace_vars;
+
/**
* Sets up the test fixtures.
*
@@ -88,6 +96,7 @@ public function set_up() {
$this->status_presenter = Mockery::mock( Status_Presenter::class );
$this->connection_permission = Mockery::mock( Connection_Permission::class );
$this->user_helper = Mockery::mock( User_Helper::class );
+ $this->replace_vars = Mockery::mock( WPSEO_Replace_Vars::class );
$endpoint_list = Mockery::mock( Endpoint_List::class );
$endpoint_list->allows( 'to_array' )->andReturn( [] );
@@ -132,6 +141,7 @@ public function set_up() {
$options_helper,
$this->user_helper,
$myyoast_connection_data_presenter,
+ $this->replace_vars,
);
}
@@ -142,6 +152,7 @@ public function set_up() {
*/
public function test_myyoast_connection_is_null_when_feature_flag_is_disabled() {
$this->myyoast_connection_conditional->expects( 'is_met' )->once()->andReturn( false );
+ $this->replace_vars->expects( 'get_replacement_variables_with_labels' )->andReturn( [] );
$data = $this->instance->get_script_data();
@@ -160,6 +171,7 @@ public function test_myyoast_connection_when_provisioned_and_can_connect() {
$this->myyoast_connection_conditional->expects( 'is_met' )->once()->andReturn( true );
$this->status_presenter->expects( 'present' )->once()->andReturn( [ 'is_provisioned' => true ] );
$this->connection_permission->expects( 'can_manage' )->once()->andReturn( true );
+ $this->replace_vars->expects( 'get_replacement_variables_with_labels' )->andReturn( [] );
$data = $this->instance->get_script_data();
$connection = $data['myyoastConnection'];
@@ -180,6 +192,7 @@ public function test_myyoast_connection_when_not_provisioned_and_cannot_connect(
$this->myyoast_connection_conditional->expects( 'is_met' )->once()->andReturn( true );
$this->status_presenter->expects( 'present' )->once()->andReturn( [ 'is_provisioned' => false ] );
$this->connection_permission->expects( 'can_manage' )->once()->andReturn( false );
+ $this->replace_vars->expects( 'get_replacement_variables_with_labels' )->andReturn( [] );
$data = $this->instance->get_script_data();
$connection = $data['myyoastConnection'];
@@ -198,6 +211,7 @@ public function test_is_provisioned_is_false_when_not_a_boolean() {
$this->myyoast_connection_conditional->expects( 'is_met' )->once()->andReturn( true );
$this->status_presenter->expects( 'present' )->once()->andReturn( [ 'is_provisioned' => 1 ] );
$this->connection_permission->expects( 'can_manage' )->once()->andReturn( false );
+ $this->replace_vars->expects( 'get_replacement_variables_with_labels' )->andReturn( [] );
$data = $this->instance->get_script_data();