Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
63f70c6
fix(bulk-editor): resolve post type default template when SEO title/d…
vraja-pro Aug 4, 2026
7f52065
refactor: remove redundant constructor from Post_Meta_Posts_Collector…
vraja-pro Aug 4, 2026
a4ea615
feat(bulk-editor): extend default template resolution to social title…
vraja-pro Aug 4, 2026
edd499a
fix(bulk-editor): gate social title/description resolution on OpenGra…
vraja-pro Aug 5, 2026
373b5ff
refactor: move replacement variables slice to shared admin folder
vraja-pro Aug 7, 2026
8f55ea4
fix: add the replacement variables to the window object
vraja-pro Aug 7, 2026
aa59cef
fix: returns the temple for the replacement variable use
vraja-pro Aug 7, 2026
0d9550d
tests: for bulk editor integration the replacement variable temples
vraja-pro Aug 7, 2026
e1b5673
fix: add shared replacement variable slice
vraja-pro Aug 7, 2026
17aedd1
fix: add replacement variable type to fields set
vraja-pro Aug 7, 2026
459310a
feat(bulk-editor): add replacement variable editor with styled sugges…
vraja-pro Aug 7, 2026
4715785
fix: hide label for replacement variable fields
vraja-pro Aug 7, 2026
e7f2110
fix tests for bulk editor
vraja-pro Aug 7, 2026
cd839ce
tests: fix console warnings
vraja-pro Aug 7, 2026
c1cc52f
tests: fix cs
vraja-pro Aug 7, 2026
bd88b48
fix(bulk-editor): read social title/description templates from option…
vraja-pro Aug 7, 2026
89b05d8
docs(bulk-editor): clarify Default_Template_Resolver class docblock
vraja-pro Aug 7, 2026
6afa8a9
refactor(bulk-editor): extract private resolve() helper in Default_Te…
vraja-pro Aug 7, 2026
7d98239
fix(bulk-editor): split editable field from display fallback to preve…
vraja-pro Aug 7, 2026
8cc785f
fix(bulk-editor): exclude template-defaulted posts from needs-improve…
vraja-pro Aug 7, 2026
c91aed3
fix(bulk-editor): remove unreachable ?? fallback in build_needs_impro…
vraja-pro Aug 7, 2026
9641450
tests(bulk-editor): add missing needs-improvement and null-post coverage
vraja-pro Aug 7, 2026
0be9715
tests(bulk-editor): tighten post-meta collector resolver assertions a…
vraja-pro Aug 7, 2026
86123f9
tests: fix cs
vraja-pro Aug 7, 2026
63c9d51
fix(bulk-editor): return stored social title/description before OpenG…
vraja-pro Aug 7, 2026
280a131
fix(bulk-editor): complexity and cleanup of table cell components
vraja-pro Aug 10, 2026
d3bba77
tests(bulk editor): fix my yoast connection tests
vraja-pro Aug 10, 2026
b48cd8a
Merge branch 'feature/bulk-editor-ph2' into 1370-bulk-editor-seo-titl…
vraja-pro Aug 10, 2026
e294378
Merge branch 'feature/bulk-editor-ph2' into 1370-bulk-editor-seo-titl…
vraja-pro Aug 10, 2026
95442eb
test: fix test after feature branch merge
vraja-pro Aug 10, 2026
2ded23b
tests: fix tests for bulk editor
vraja-pro Aug 10, 2026
47c404b
Merge remote-tracking branch 'origin/feature/bulk-editor-ph2' into 13…
vraja-pro Aug 11, 2026
714f762
fix: tests for bulk editor integration
vraja-pro Aug 11, 2026
d3ecffe
fix: fix php cs
vraja-pro Aug 11, 2026
873333f
Merge remote-tracking branch 'origin/feature/bulk-editor-ph2' into 13…
vraja-pro Aug 12, 2026
65e4199
fix(bulk-editor): show fallback template as pills when opening edit f…
vraja-pro Aug 12, 2026
0695fc4
fix(bulk-editor): make ReplacementVariableEditor the default cell bra…
vraja-pro Aug 12, 2026
0fd7bfb
fix(bulk-editor): seed store with first content type so replacement v…
vraja-pro Aug 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions css/src/bulk-editor-page.css
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -167,6 +170,7 @@
}

.yst-root .yst-bulk-editor-textarea-field textarea {
@apply yst-min-h-16;
field-sizing: content;
}

Expand Down Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { __ } from "@wordpress/i18n";
import { TextareaField } from "@yoast/ui-library";
import { EditableFieldCell } from "./table-cells";

/**
* Focus keyphrase editable field cell.
Expand All @@ -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" ),
Expand All @@ -27,12 +26,13 @@ export const FocusKeyphraseEditableFieldCell = ( { value, ...props } ) => {
: null;

return (
<EditableFieldCell
as={ TextareaField }
{ ...props }
value={ value }
<TextareaField
className="yst-bulk-editor-textarea-field"
validation={ validation }
maxLength={ 191 }
rows={ 2 }
{ ...props }
value={ value }
/>
);
};
Original file line number Diff line number Diff line change
@@ -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 (
<Table.Cell key={ field.key } className="yst-bulk-editor-cell-value">
{ item[ field.key ] }
</Table.Cell>
);
}

return (
<Table.Cell key={ field.key } className="yst-bulk-editor-cell-value">
<span id={ `bulk-editor-preview-${ field.key }-${ item.id }` } className="yst-sr-only">
{ 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 ) }
</span>
<ReplacementVariableEditorStandalone
Comment thread
FAMarfuaty marked this conversation as resolved.
content={ item[ field.key ] || item[ `${ field.key }Fallback` ] || "" }
onChange={ noop }
type={ field.type }
isDisabled={ true }
replacementVariables={ replacementVariables }
recommendedReplacementVariables={ recommendedReplacementVariables }
ariaLabelledBy={ `bulk-editor-preview-${ field.key }-${ item.id }` }
fieldId={ `bulk-editor-${ field.key }-${ item.id }` }
/>
</Table.Cell>
);
};
101 changes: 68 additions & 33 deletions packages/js/src/bulk-editor/components/table/table-cells.js
Original file line number Diff line number Diff line change
@@ -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).
Expand Down Expand Up @@ -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 (
<Table.Cell>
<AnimateHeight easing="ease-out" duration={ 100 } height={ height } animateOpacity={ true }>
<Component
id={ `bulk-editor-edit-${ itemId }-${ fieldSetId }-${ field.key }` }
className="yst-bulk-editor-textarea-field"
{ ...props }
rows={ 2 }
value={ value }
onChange={ handleChange }
disabled={ isSaving }
/* translators: %1$s expands to the field label, %2$s to the content item title. */
aria-label={ sprintf( __( "%1$s for %2$s", "wordpress-seo" ), field.label, itemTitle ) }
/>
</AnimateHeight>
</Table.Cell>
);
// 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 (
<Table.Cell>
<AnimateHeight easing="ease-out" duration={ 100 } height={ height } animateOpacity={ true }>
<FocusKeyphraseField
id={ id }
aria-label={ label }
value={ value }
disabled={ isSaving }
onChange={ handleTextareaChange }
rows={ 2 }
/>
</AnimateHeight>
</Table.Cell>
);
}

return ( <Table.Cell>
<AnimateHeight easing="ease-out" duration={ 100 } height={ height } animateOpacity={ true }>
<ReplacementVariableEditor
content={ editorContent }
onChange={ handleReplaceVarChange }
type={ field.type }
isDisabled={ isSaving }
fieldId={ id }
replacementVariables={ replacementVariables }
recommendedReplacementVariables={ recommendedReplacementVariables }
/* translators: %1$s expands to the field label, %2$s to the content item title. */
label={ label }
/>
</AnimateHeight>
</Table.Cell> );
};
Comment thread
vraja-pro marked this conversation as resolved.
36 changes: 18 additions & 18 deletions packages/js/src/bulk-editor/components/table/table-row.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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();
Comment thread
FAMarfuaty marked this conversation as resolved.
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}` );
Expand Down Expand Up @@ -104,25 +112,15 @@ export const BulkEditorRow = ( {

if ( ! openFields.includes( field.key ) ) {
return (
<Table.Cell key={ field.key } className="yst-bulk-editor-cell-value">
{ item[ field.key ] }
</Table.Cell>
<PreviewEditableFieldCell
field={ field }
item={ item }
replacementVariables={ replacementVariables }
recommendedReplacementVariables={ recommendedReplacementVariables }
/>
);
}

if ( field.key === FOCUS_KEYPHRASE_KEY ) {
// The focus keyphrase has more warnings.
return <FocusKeyphraseEditableFieldCell
field={ field }
itemId={ item.id }
fieldSetId={ fieldSetId }
itemTitle={ item.title }
value={ draft[ field.key ] ?? "" }
isSaving={ isSaving }
onChange={ handleChangeField }
/>;
}

return <EditableFieldCell
field={ field }
itemId={ item.id }
Expand All @@ -131,6 +129,8 @@ export const BulkEditorRow = ( {
isSaving={ isSaving }
fieldSetId={ fieldSetId }
onChange={ handleChangeField }
replacementVariables={ replacementVariables }
recommendedReplacementVariables={ recommendedReplacementVariables }
/>;
} }
</Slot>
Expand Down
21 changes: 11 additions & 10 deletions packages/js/src/bulk-editor/field-sets.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/

/**
Expand Down Expand Up @@ -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 ]: {
Expand All @@ -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" },
],
},
};
Expand Down
Loading
Loading