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();