diff --git a/css/src/bulk-editor-page.css b/css/src/bulk-editor-page.css index 6e0b92e72ef..cbb90ec9847 100644 --- a/css/src/bulk-editor-page.css +++ b/css/src/bulk-editor-page.css @@ -102,6 +102,12 @@ @apply yst-m-0; } +/* The "Overview selection" filter group. It is separated from the status filters below it by a divider, + * mirroring the one above the "needs improvement" group. */ +.yst-root .yst-bulk-editor-overview-selection { + @apply yst-border-b yst-border-slate-200 yst-mb-0.5 yst-pb-0.5; +} + /* The "needs improvement" filter group. It is separated from the status filters by a divider; each option is * prefixed with a red score dot; and the group's legend is hidden visually but kept for assistive tech, so the * dot's "needs improvement" meaning is never conveyed by colour alone. */ diff --git a/packages/js/src/bulk-editor/components/bulk-action-bar.js b/packages/js/src/bulk-editor/components/bulk-action-bar.js index ae5a4f82f55..345ed34b6a6 100644 --- a/packages/js/src/bulk-editor/components/bulk-action-bar.js +++ b/packages/js/src/bulk-editor/components/bulk-action-bar.js @@ -1,12 +1,14 @@ import CheckIcon from "@heroicons/react/outline/CheckIcon"; import XIcon from "@heroicons/react/outline/XIcon"; -import SolidXIcon from "@heroicons/react/solid/XIcon"; import { Slot } from "@wordpress/components"; import { useEffect, useId, useRef } from "@wordpress/element"; import { __, _n, sprintf } from "@wordpress/i18n"; -import { Alert, Button, Checkbox, useSvgAria, useToggleState } from "@yoast/ui-library"; +import { Button, Checkbox, useSvgAria, useToggleState } from "@yoast/ui-library"; import { BULK_ACTIONS_SLOT, BULK_NOTICES_SLOT } from "../constants"; import { useAiUpsell } from "../hooks/use-ai-upsell"; +import { DismissibleAlert } from "./dismissible-alert"; +import { OverviewExclusionNotice } from "./overview-exclusion-notice"; +import { OverviewSelectionNotice } from "./overview-selection-notice"; import { UpsellModal } from "./upsell-modal"; import { SelectMenu } from "./select-menu"; @@ -142,29 +144,25 @@ export const ManualReviewActions = ( { editCount, onApplyAll, onDiscardAll, isAp * * @returns {JSX.Element} The save-error notice. */ -export const ManualSaveErrorNotice = ( { onDismiss } ) => { - const svgAriaProps = useSvgAria(); - return +export const ManualSaveErrorNotice = ( { onDismiss } ) => ( +
{ __( "Couldn't save your edits.", "wordpress-seo" ) } { __( "Something went wrong. Please try again.", "wordpress-seo" ) }
- -
; -}; + +); /** - * The Free save-error notice, and the alerts slot Premium fills (e.g. its AI alerts). - * Only rendered on the active tab, so each tab has a single slot to target. + * The overview-selection truncation and exclusion notices, the Free save-error notice, and the alerts slot + * Premium fills (e.g. its AI alerts). Only rendered on the active tab, so each tab has a single slot to target. + * The truncation and exclusion notices are independent and can show at the same time. * * @param {Object} props The props. + * @param {number} [props.preselectedTotal] How many items were selected on the WP admin overview; shows the truncation notice. + * @param {Function} [props.onDismissPreselection] Dismisses the truncation notice. + * @param {boolean} [props.hasExcludedPreselected] Whether carried-over items were dropped; shows the exclusion notice. + * @param {Function} [props.onDismissExclusion] Dismisses the exclusion notice. * @param {boolean} [props.hasSaveError] Whether the last apply-all failed; shows the save-error notice. * @param {Function} [props.onDismissSaveError] Dismisses the save-error notice. * @param {number[]} props.selectedIds The ids of the selected rows, passed to the notices fill. @@ -176,9 +174,12 @@ export const ManualSaveErrorNotice = ( { onDismiss } ) => { * @returns {JSX.Element} The notices region. */ const BulkActionsNotices = ( { - hasSaveError, onDismissSaveError, selectedIds, activeFieldSet, contentType, contentTypeLabel, contentTypeSingularLabel, + preselectedTotal = 0, onDismissPreselection, hasExcludedPreselected = false, onDismissExclusion, hasSaveError, onDismissSaveError, + selectedIds, activeFieldSet, contentType, contentTypeLabel, contentTypeSingularLabel, } ) => ( <> + + { hasSaveError && } (
{ isActive && ( + ( hasSelection && isAiEnabled ) || hasUnsavedEdits || hasExternalPendingChanges || hasOverviewNotice; + +/** + * Decides whether an overview-selection notice (truncation or exclusion) must show. + * + * @param {Object} view The view state. + * @param {number} view.preselectedTotal How many items were selected on the WP admin overview. + * @param {boolean} view.hasExcludedPreselected Whether pruning dropped carried-over ids. + * + * @returns {boolean} Whether an overview-selection notice must show. + */ +export const getHasOverviewNotice = ( { preselectedTotal, hasExcludedPreselected } ) => + preselectedTotal > BULK_UPDATE_BATCH_SIZE || hasExcludedPreselected; + /** * The bulk editor content. * @@ -39,6 +74,8 @@ export const BulkEditorContent = ( { dataProvider, remoteDataProvider, contentTy const { activeFieldSet, selectedIds, + preselectedTotal, + hasExcludedPreselected, isPremium, isAiEnabled, hasExternalPendingChanges, @@ -49,6 +86,10 @@ export const BulkEditorContent = ( { dataProvider, remoteDataProvider, contentTy return { activeFieldSet: store.selectActiveFieldSet(), selectedIds: store.selectSelectedIds(), + // The size of a selection carried over from the WP admin overview; drives the truncation notice. + preselectedTotal: store.selectPreselectedTotal(), + // Whether pruning dropped carried-over ids the bulk editor cannot show or edit; drives the exclusion notice. + hasExcludedPreselected: store.selectHasExcludedPreselected(), isPremium: store.selectPreference( "isPremium", false ), isAiEnabled: store.selectPreference( "isAiEnabled", false ), // An external plugin (e.g. Premium's AI suggestions) reports pending changes so the switch can be guarded. @@ -58,7 +99,9 @@ export const BulkEditorContent = ( { dataProvider, remoteDataProvider, contentTy pendingSwitch: store.selectPendingSwitch(), }; }, [] ); - const { requestSwitch, commitSwitch, clearPendingSwitch, toggleRow, selectAll, deselectAll } = useDispatch( STORE_NAME ); + const { + requestSwitch, commitSwitch, clearPendingSwitch, toggleRow, selectAll, deselectAll, dismissPreselectionNotice, dismissExclusionNotice, + } = useDispatch( STORE_NAME ); const { data: items = [], total = 0, totalPages = 0, isPending, updateItem } = usePosts( { dataProvider, remoteDataProvider, contentType } ); const { editing, stopEditing } = useInlineEdit( { dataProvider, remoteDataProvider, fieldSets, activeFieldSet, items, updateItem } ); @@ -105,6 +148,10 @@ export const BulkEditorContent = ( { dataProvider, remoteDataProvider, contentTy }, [ pendingSwitch, hasUnsavedEdits, hasExternalPendingChanges, onCommitSwitch ] ); const { isAllSelected, isIndeterminate, selectedCount, totalCount, hasSelection } = getSelectionView( isPending, selectedIds, items, total ); + // The truncation and exclusion notices for a selection carried over from the WP admin overview, shown in the + // band's notices region; either one keeps the band expanded. + const hasOverviewNotice = getHasOverviewNotice( { preselectedTotal, hasExcludedPreselected } ); + const showBulkActions = shouldShowBulkActions( { hasSelection, isAiEnabled, hasUnsavedEdits, hasExternalPendingChanges, hasOverviewNotice } ); const onSelectAll = useCallback( () => { if ( ! isPending ) { // Only posts the user can edit are selectable for bulk editing. @@ -174,14 +221,13 @@ export const BulkEditorContent = ( { dataProvider, remoteDataProvider, contentTy isApplyingAll={ editing.isApplyingAll } hasSaveError={ editing.hasSaveError } onDismissSaveError={ editing.dismissSaveError } + preselectedTotal={ preselectedTotal } + onDismissPreselection={ dismissPreselectionNotice } + hasExcludedPreselected={ hasExcludedPreselected } + onDismissExclusion={ dismissExclusionNotice } /> } - // A selection only warrants the band while AI is enabled (the AI affordances are its only - // selection-driven occupant); with AI off the band collapses. Unsaved manual edits are a - // separate, non-AI occupant, so they keep it open regardless of the AI toggle. External - // pending changes (Premium's AI suggestions) also keep it open: a filter, search, or page - // change clears the selection but must leave the pending suggestions actionable. - showBulkActions={ ( hasSelection && isAiEnabled ) || hasUnsavedEdits || hasExternalPendingChanges } + showBulkActions={ showBulkActions } filters={ } isLoading={ isPending } hasExternalPendingChanges={ hasExternalPendingChanges } diff --git a/packages/js/src/bulk-editor/components/bulk-editor-filters.js b/packages/js/src/bulk-editor/components/bulk-editor-filters.js index 3317cc1c5d1..1d029a474f0 100644 --- a/packages/js/src/bulk-editor/components/bulk-editor-filters.js +++ b/packages/js/src/bulk-editor/components/bulk-editor-filters.js @@ -6,8 +6,9 @@ import { Badge, Button, CheckboxGroup, Popover, useSvgAria } from "@yoast/ui-lib import { FIELD_SET_SOCIAL, NEEDS_IMPROVEMENT_DESCRIPTION, NEEDS_IMPROVEMENT_TITLE, STORE_NAME } from "../constants"; /** - * The Filters button and popover: narrows the table by post status and by which fields need improvement. - * A badge shows how many filters are applied. + * The Filters button and popover: narrows the table by post status, by which fields need improvement + * and, when a selection was carried over from the WP admin overview, by that selection. A badge shows + * how many filters are applied. * * The "needs improvement" options are tab-agnostic (values {@link NEEDS_IMPROVEMENT_TITLE} / * {@link NEEDS_IMPROVEMENT_DESCRIPTION}); only their labels change with the active tab, so a checked box @@ -19,7 +20,9 @@ export const BulkEditorFilters = () => { const statuses = useSelect( ( select ) => select( STORE_NAME ).selectStatuses(), [] ); const needsImprovement = useSelect( ( select ) => select( STORE_NAME ).selectNeedsImprovement(), [] ); const activeFieldSet = useSelect( ( select ) => select( STORE_NAME ).selectActiveFieldSet(), [] ); - const { setStatuses, setNeedsImprovement } = useDispatch( STORE_NAME ); + const overviewIds = useSelect( ( select ) => select( STORE_NAME ).selectOverviewIds(), [] ); + const isOverviewFilterActive = useSelect( ( select ) => select( STORE_NAME ).selectIsOverviewFilterActive(), [] ); + const { setStatuses, setNeedsImprovement, setOverviewFilterActive } = useDispatch( STORE_NAME ); const [ isOpen, setIsOpen ] = useState( false ); const containerRef = useRef( null ); const triggerRef = useRef( null ); @@ -32,6 +35,15 @@ export const BulkEditorFilters = () => { { value: "draft", label: __( "Draft", "wordpress-seo" ) }, ], [] ); + const overviewOptions = useMemo( () => [ + { value: "overview", label: __( "Overview selection", "wordpress-seo" ) }, + ], [] ); + + const onChangeOverviewFilter = useCallback( + ( values ) => setOverviewFilterActive( values.includes( "overview" ) ), + [ setOverviewFilterActive ] + ); + const isSocial = activeFieldSet === FIELD_SET_SOCIAL; // The red dot in front of each option (and the group's "needs improvement" legend) carries the // "needs improvement" meaning, so the visible labels are the plain field names. @@ -79,7 +91,7 @@ export const BulkEditorFilters = () => { }; }, [ isOpen ] ); - const appliedCount = statuses.length + needsImprovement.length; + const appliedCount = statuses.length + needsImprovement.length + ( isOverviewFilterActive ? 1 : 0 ); return (
@@ -95,6 +107,15 @@ export const BulkEditorFilters = () => { className="before:yst-hidden !yst-top-full yst-mt-1 yst-py-0.5 yst-px-0 yst-shadow-lg" aria-label={ __( "Filters", "wordpress-seo" ) } > + { overviewIds.length > 0 && ( + + ) } { + const svgAriaProps = useSvgAria(); + + return ( + + { children } + + + ); +}; diff --git a/packages/js/src/bulk-editor/components/overview-exclusion-notice.js b/packages/js/src/bulk-editor/components/overview-exclusion-notice.js new file mode 100644 index 00000000000..7f7e148a6d6 --- /dev/null +++ b/packages/js/src/bulk-editor/components/overview-exclusion-notice.js @@ -0,0 +1,28 @@ +import { __ } from "@wordpress/i18n"; +import { DismissibleAlert } from "./dismissible-alert"; + +/** + * The notice shown when a selection carried over from a WP admin overview contained items the bulk editor + * cannot show or edit: those were dropped from the selection. Renders nothing while nothing was dropped. + * + * @param {Object} props The props. + * @param {boolean} props.hasExclusions Whether carried-over items were dropped from the selection. + * @param {Function} props.onDismiss Dismisses the notice. + * + * @returns {?JSX.Element} The notice, or null while nothing was dropped. + */ +export const OverviewExclusionNotice = ( { hasExclusions, onDismiss } ) => { + if ( ! hasExclusions ) { + return null; + } + + return ( + // The top margin separates this notice from the truncation notice above it; it cancels out when + // nothing precedes it in the notices region (the truncation notice renders null when it does not apply). + + + { __( "Your selection has been updated. Private, password-protected, or non-indexed items can't be bulk edited and were excluded.", "wordpress-seo" ) } + + + ); +}; diff --git a/packages/js/src/bulk-editor/components/overview-selection-notice.js b/packages/js/src/bulk-editor/components/overview-selection-notice.js new file mode 100644 index 00000000000..847c77faa4d --- /dev/null +++ b/packages/js/src/bulk-editor/components/overview-selection-notice.js @@ -0,0 +1,32 @@ +import { __, sprintf } from "@wordpress/i18n"; +import { BULK_UPDATE_BATCH_SIZE } from "../constants"; +import { DismissibleAlert } from "./dismissible-alert"; + +/** + * The notice shown when the user arrived from a WP admin overview with more items selected than the + * bulk editor can handle in one batch: only the first batch stays selected. Renders nothing while the + * whole selection fits the batch. + * + * @param {Object} props The props. + * @param {number} props.total The number of items that were selected on the overview. + * @param {Function} props.onDismiss Dismisses the notice. + * + * @returns {?JSX.Element} The notice, or null when the whole selection fits the batch. + */ +export const OverviewSelectionNotice = ( { total, onDismiss } ) => { + if ( total <= BULK_UPDATE_BATCH_SIZE ) { + return null; + } + + const message = sprintf( + /* translators: %1$d expands to the maximum number of items at a time. */ + __( "Only the first %1$d items from your selection were carried over. The bulk editor supports up to %1$d items at a time.", "wordpress-seo" ), + BULK_UPDATE_BATCH_SIZE + ); + + return ( + + { message } + + ); +}; diff --git a/packages/js/src/bulk-editor/helpers/get-selection-view.js b/packages/js/src/bulk-editor/helpers/get-selection-view.js index edfa3d1ccdf..f0774ab2be5 100644 --- a/packages/js/src/bulk-editor/helpers/get-selection-view.js +++ b/packages/js/src/bulk-editor/helpers/get-selection-view.js @@ -14,9 +14,11 @@ export const getSelectionView = ( isLoading, selectedIds, items, total ) => { return { isAllSelected: false, isIndeterminate: false, selectedCount: 0, totalCount: 0, hasSelection: false }; } // Only posts the user can edit are selectable, so "all selected" is measured against the editable rows. - const selectableCount = items.filter( ( item ) => item.editable ).length; + // Measured by membership, not count: a selection carried over from the WP admin overview can contain + // rows that are not on the current page. + const selectableIds = items.filter( ( item ) => item.editable ).map( ( item ) => item.id ); const selectedCount = selectedIds.length; - const isAllSelected = selectableCount > 0 && selectedCount === selectableCount; + const isAllSelected = selectableIds.length > 0 && selectableIds.every( ( id ) => selectedIds.includes( id ) ); return { isAllSelected, isIndeterminate: selectedCount > 0 && ! isAllSelected, diff --git a/packages/js/src/bulk-editor/hooks/use-posts.js b/packages/js/src/bulk-editor/hooks/use-posts.js index cdd5b0684bc..803f3616623 100644 --- a/packages/js/src/bulk-editor/hooks/use-posts.js +++ b/packages/js/src/bulk-editor/hooks/use-posts.js @@ -1,4 +1,4 @@ -import { useSelect } from "@wordpress/data"; +import { useDispatch, useSelect } from "@wordpress/data"; import { useCallback, useEffect, useMemo, useRef, useState } from "@wordpress/element"; import { NEEDS_IMPROVEMENT_FIELD_PARAMS, PAGE_SIZE, STORE_NAME } from "../constants"; @@ -66,6 +66,9 @@ export const usePosts = ( { dataProvider, remoteDataProvider, contentType } ) => const statuses = useSelect( ( select ) => select( STORE_NAME ).selectStatuses(), [] ); const needsImprovement = useSelect( ( select ) => select( STORE_NAME ).selectNeedsImprovement(), [] ); const activeFieldSet = useSelect( ( select ) => select( STORE_NAME ).selectActiveFieldSet(), [] ); + const overviewIds = useSelect( ( select ) => select( STORE_NAME ).selectOverviewIds(), [] ); + const isOverviewFilterActive = useSelect( ( select ) => select( STORE_NAME ).selectIsOverviewFilterActive(), [] ); + const { pruneSelection } = useDispatch( STORE_NAME ); // Resolve the tab-agnostic "needs improvement" concepts to the active tab's concrete field params. const needsImprovementFields = useMemo( () => { @@ -91,24 +94,36 @@ export const usePosts = ( { dataProvider, remoteDataProvider, contentType } ) => setState( ( previous ) => ( { ...previous, isPending: true } ) ); + const params = { + /* eslint-disable camelcase -- The REST endpoint expects snake_case query parameters. */ + content_type: contentType, + per_page: String( PAGE_SIZE ), + page: String( page ), + search, + status: statuses, + needs_improvement: needsImprovementFields, + /* eslint-enable camelcase */ + }; + // The "Overview selection" filter narrows the list to the posts carried over from the WP admin overview. + if ( isOverviewFilterActive && overviewIds.length > 0 ) { + params.include = overviewIds.map( String ); + } + remoteDataProvider .fetchJson( endpoint, - { - /* eslint-disable camelcase -- The REST endpoint expects snake_case query parameters. */ - content_type: contentType, - per_page: String( PAGE_SIZE ), - page: String( page ), - search, - status: statuses, - needs_improvement: needsImprovementFields, - /* eslint-enable camelcase */ - }, + params, { signal: current.signal } ) .then( ( response ) => { if ( controller.current === current ) { - setState( formatResponse( response ) ); + const settled = formatResponse( response ); + setState( settled ); + // While the overview filter is active, the response is the authoritative subset of the + // carried-over selection (at most one page): prune ids it cannot offer for selection. + if ( isOverviewFilterActive && overviewIds.length > 0 ) { + pruneSelection( settled.data.filter( ( item ) => item.editable ).map( ( item ) => item.id ) ); + } } } ) .catch( ( error ) => { @@ -119,7 +134,10 @@ export const usePosts = ( { dataProvider, remoteDataProvider, contentType } ) => } ); return () => current.abort(); - }, [ endpoint, contentType, remoteDataProvider, search, page, statuses, needsImprovementFields ] ); + }, [ + endpoint, contentType, remoteDataProvider, search, page, statuses, + needsImprovementFields, overviewIds, isOverviewFilterActive, pruneSelection, + ] ); return { ...state, updateItem }; }; diff --git a/packages/js/src/bulk-editor/initialize.js b/packages/js/src/bulk-editor/initialize.js index 4fc69adb870..198cb6f741c 100644 --- a/packages/js/src/bulk-editor/initialize.js +++ b/packages/js/src/bulk-editor/initialize.js @@ -11,7 +11,7 @@ import { fixWordPressMenuScrolling } from "../shared-admin/helpers"; import { getMyyoastConnectionState, LINK_PARAMS_NAME, MYYOAST_CONNECTION_NAME } from "../shared-admin/store"; import App from "./app"; import { UpsellModal } from "./components/upsell-modal"; -import { PLUGIN_SCOPE, ROOT_ID, STORE_NAME } from "./constants"; +import { BULK_UPDATE_BATCH_SIZE, PLUGIN_SCOPE, ROOT_ID, STORE_NAME } from "./constants"; import { useAiUpsell } from "./hooks/use-ai-upsell"; import { DataProvider } from "./services"; import registerStore from "./store"; @@ -24,6 +24,33 @@ window.yoast.bulkEditor = window.yoast.bulkEditor || {}; window.yoast.bulkEditor.components = { ...window.yoast.bulkEditor.components, UpsellModal, GenericAlert }; window.yoast.bulkEditor.hooks = { ...window.yoast.bulkEditor.hooks, useAiUpsell }; +/** + * Builds the store state for a selection carried over from a WP admin overview bulk action. + * + * @param {Object} [initialSelection] The carried-over selection ({ contentType, postIds, selectedCount }), if any. + * + * @returns {Object} The seeded state: the active content type, the selection and the overview filter. + */ +export const getPreselectionState = ( initialSelection = {} ) => { + const selectedIds = ( Array.isArray( initialSelection.postIds ) ? initialSelection.postIds : [] ) + .map( Number ) + .filter( ( id ) => Number.isInteger( id ) && id > 0 ) + .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, + preselectedTotal: selectedIds.length > 0 ? Math.max( Number( initialSelection.selectedCount ) || 0, selectedIds.length ) : 0, + }, + query: { + overviewIds: selectedIds, + isOverviewFilterActive: selectedIds.length > 0, + }, + }; +}; + domReady( () => { const root = document.getElementById( ROOT_ID ); if ( ! root ) { @@ -36,6 +63,7 @@ domReady( () => { initialState: { [ LINK_PARAMS_NAME ]: get( window, "wpseoBulkEditorData.linkParams", {} ), [ MYYOAST_CONNECTION_NAME ]: getMyyoastConnectionState( myyoastConnection ), + ...getPreselectionState( get( window, "wpseoBulkEditorData.initialSelection", {} ) ), }, } ); fixWordPressMenuScrolling(); diff --git a/packages/js/src/bulk-editor/store/query.js b/packages/js/src/bulk-editor/store/query.js index ee130a7ec13..72f9dbb90d1 100644 --- a/packages/js/src/bulk-editor/store/query.js +++ b/packages/js/src/bulk-editor/store/query.js @@ -3,9 +3,19 @@ import { get } from "lodash"; import { activeContentTypeActions } from "./active-content-type"; /** - * @returns {{search: string, page: number, statuses: string[], needsImprovement: string[]}} The initial table query state. + * @returns {{search: string, page: number, statuses: string[], needsImprovement: string[], + * overviewIds: number[], isOverviewFilterActive: boolean}} The initial table query state. */ -export const createInitialQueryState = () => ( { search: "", page: 1, statuses: [], needsImprovement: [] } ); +export const createInitialQueryState = () => ( { + search: "", + page: 1, + statuses: [], + needsImprovement: [], + // The post IDs carried over from the WP admin overview, backing the "Overview selection" filter; + // empty when the user did not come in via the overview's bulk action. + overviewIds: [], + isOverviewFilterActive: false, +} ); const slice = createSlice( { name: "query", @@ -29,11 +39,19 @@ const slice = createSlice( { state.needsImprovement = payload; state.page = 1; }, + // Toggling the overview filter resets to the first page, like any other change to the result set. + setOverviewFilterActive: ( state, { payload } ) => { + state.isOverviewFilterActive = Boolean( payload ); + state.page = 1; + }, }, extraReducers: ( builder ) => { - // Switching content type resets to the first page: the current page may not exist in the new content type's results. + // Switching content type resets to the first page: the current page may not exist in the new content + // type's results. The overview selection belongs to the content type it was made for, so it goes too. builder.addCase( activeContentTypeActions.setActiveContentType, ( state ) => { state.page = 1; + state.overviewIds = []; + state.isOverviewFilterActive = false; } ); }, } ); @@ -43,6 +61,8 @@ export const querySelectors = { selectPage: ( state ) => get( state, "query.page", 1 ), selectStatuses: ( state ) => get( state, "query.statuses", [] ), selectNeedsImprovement: ( state ) => get( state, "query.needsImprovement", [] ), + selectOverviewIds: ( state ) => get( state, "query.overviewIds", [] ), + selectIsOverviewFilterActive: ( state ) => get( state, "query.isOverviewFilterActive", false ), selectQuery: ( state ) => get( state, "query", createInitialQueryState() ), }; diff --git a/packages/js/src/bulk-editor/store/selection.js b/packages/js/src/bulk-editor/store/selection.js index feb058e80cd..9ae78530404 100644 --- a/packages/js/src/bulk-editor/store/selection.js +++ b/packages/js/src/bulk-editor/store/selection.js @@ -5,10 +5,16 @@ import { activeFieldSetActions } from "./active-field-set"; import { queryActions } from "./query"; /** - * @returns {Object} The initial selection state: no rows selected. + * @returns {Object} The initial selection state: no rows selected, no selection carried over from the WP admin overview. */ export const createInitialSelectionState = () => ( { selectedIds: [], + // How many items were selected on the WP admin overview when the user came in via its bulk action; drives the + // "only the first 20 are selected" notice. 0 when the user did not come in that way (or the notice was dismissed). + preselectedTotal: 0, + // Whether pruning dropped carried-over ids the bulk editor cannot show or edit; drives the exclusion notice. + // False when nothing was dropped (or the notice was dismissed). + hasExcludedPreselected: false, } ); const slice = createSlice( { @@ -24,15 +30,38 @@ const slice = createSlice( { }, selectAll: ( state, { payload } ) => { state.selectedIds = [ ...payload ]; + // An explicit select-all replaces the carried-over selection, so its notices would be stale. + state.preselectedTotal = 0; + state.hasExcludedPreselected = false; }, deselectAll: () => createInitialSelectionState(), + // A selection carried over from the WP admin overview can reference posts the bulk editor does not list + // (e.g. private posts) or lists with a disabled checkbox (non-editable rows). Once the shown result set is + // known, such ids must be dropped: they would stay selected without a way to deselect them, and leak into + // the bulk actions. Dropping any flags the exclusion notice, so the user learns why their selection shrank. + pruneSelection: ( state, { payload } ) => { + const pruned = state.selectedIds.filter( ( id ) => payload.includes( id ) ); + if ( pruned.length < state.selectedIds.length ) { + state.hasExcludedPreselected = true; + } + state.selectedIds = pruned; + }, + dismissPreselectionNotice: ( state ) => { + state.preselectedTotal = 0; + }, + dismissExclusionNotice: ( state ) => { + state.hasExcludedPreselected = false; + }, }, extraReducers: ( builder ) => { // Any change to the shown result set resets the selection: the new set may no longer contain the selected rows. + // The "Overview selection" filter (query slice) intentionally outlives these resets: it narrows the result set + // like any other filter, while the carried-over selection itself is one-shot and never restored. builder.addCase( queryActions.setStatuses, () => createInitialSelectionState() ); builder.addCase( queryActions.setSearch, () => createInitialSelectionState() ); builder.addCase( queryActions.setNeedsImprovement, () => createInitialSelectionState() ); builder.addCase( queryActions.setPage, () => createInitialSelectionState() ); + builder.addCase( queryActions.setOverviewFilterActive, () => createInitialSelectionState() ); builder.addCase( activeContentTypeActions.setActiveContentType, () => createInitialSelectionState() ); builder.addCase( activeFieldSetActions.setActiveFieldSet, () => createInitialSelectionState() ); }, @@ -40,6 +69,8 @@ const slice = createSlice( { export const selectionSelectors = { selectSelectedIds: ( state ) => get( state, "selection.selectedIds", [] ), + selectPreselectedTotal: ( state ) => get( state, "selection.preselectedTotal", 0 ), + selectHasExcludedPreselected: ( state ) => get( state, "selection.hasExcludedPreselected", false ), }; export const selectionActions = slice.actions; diff --git a/packages/js/tests/bulk-editor/bulk-editor-content.test.js b/packages/js/tests/bulk-editor/bulk-editor-content.test.js index a3e79473aa8..553e4f0bb35 100644 --- a/packages/js/tests/bulk-editor/bulk-editor-content.test.js +++ b/packages/js/tests/bulk-editor/bulk-editor-content.test.js @@ -1,7 +1,7 @@ import { Fill, SlotFillProvider } from "@wordpress/components"; import { dispatch } from "@wordpress/data"; import { act, fireEvent, render, screen, waitFor } from "../test-utils"; -import { BulkEditorContent } from "../../src/bulk-editor/components/bulk-editor-content"; +import { BulkEditorContent, getHasOverviewNotice, shouldShowBulkActions } from "../../src/bulk-editor/components/bulk-editor-content"; import { getSelectionView, getSmartSelectItems } from "../../src/bulk-editor/helpers"; import { FIELD_SET_SEARCH, FIELD_SET_SOCIAL, PENDING_CHANGES_MODAL_SLOT, STORE_NAME } from "../../src/bulk-editor/constants"; import { DataProvider } from "../../src/bulk-editor/services"; @@ -121,6 +121,22 @@ describe( "getSelectionView", () => { expect( view.totalCount ).toBe( 42 ); } ); + it( "does not read a carried-over selection of other rows as all selected", () => { + // Two visible rows plus an id that is not on this page (carried over from the WP admin overview). + const view = getSelectionView( false, [ 1, 2, 99 ], items, 30 ); + + expect( view.isAllSelected ).toBe( false ); + expect( view.isIndeterminate ).toBe( true ); + expect( view.selectedCount ).toBe( 3 ); + } ); + + it( "reads a selection covering every visible row as all selected, even with extra off-page rows", () => { + const view = getSelectionView( false, [ 1, 2, 3, 99 ], items, 30 ); + + expect( view.isAllSelected ).toBe( true ); + expect( view.isIndeterminate ).toBe( false ); + } ); + it( "treats only editable rows as selectable", () => { const mixed = [ { id: 1, editable: true }, { id: 2, editable: false }, { id: 3, editable: true } ]; @@ -133,6 +149,34 @@ describe( "getSelectionView", () => { } ); } ); +describe( "shouldShowBulkActions", () => { + const closed = { hasSelection: false, isAiEnabled: false, hasUnsavedEdits: false, hasExternalPendingChanges: false, hasOverviewNotice: false }; + + it( "keeps the band closed when nothing occupies it", () => { + expect( shouldShowBulkActions( closed ) ).toBe( false ); + } ); + + it( "opens the band for a selection only while AI is enabled", () => { + expect( shouldShowBulkActions( { ...closed, hasSelection: true } ) ).toBe( false ); + expect( shouldShowBulkActions( { ...closed, hasSelection: true, isAiEnabled: true } ) ).toBe( true ); + } ); + + it( "opens the band for unsaved edits, external pending changes and the overview notice", () => { + expect( shouldShowBulkActions( { ...closed, hasUnsavedEdits: true } ) ).toBe( true ); + expect( shouldShowBulkActions( { ...closed, hasExternalPendingChanges: true } ) ).toBe( true ); + expect( shouldShowBulkActions( { ...closed, hasOverviewNotice: true } ) ).toBe( true ); + } ); +} ); + +describe( "getHasOverviewNotice", () => { + it( "reports a notice for a truncated or a pruned carried-over selection, but not for a fitting one", () => { + expect( getHasOverviewNotice( { preselectedTotal: 0, hasExcludedPreselected: false } ) ).toBe( false ); + expect( getHasOverviewNotice( { preselectedTotal: 20, hasExcludedPreselected: false } ) ).toBe( false ); + expect( getHasOverviewNotice( { preselectedTotal: 25, hasExcludedPreselected: false } ) ).toBe( true ); + expect( getHasOverviewNotice( { preselectedTotal: 3, hasExcludedPreselected: true } ) ).toBe( true ); + } ); +} ); + describe( "getSmartSelectItems", () => { /* eslint-disable camelcase -- the needs-improvement map is keyed by backend field params. */ // id 3 is non-editable but needs improvement everywhere: it must never be selected. diff --git a/packages/js/tests/bulk-editor/bulk-editor-filters.test.js b/packages/js/tests/bulk-editor/bulk-editor-filters.test.js index b83f325a7b4..b0a59887f72 100644 --- a/packages/js/tests/bulk-editor/bulk-editor-filters.test.js +++ b/packages/js/tests/bulk-editor/bulk-editor-filters.test.js @@ -6,7 +6,9 @@ import registerStore from "../../src/bulk-editor/store"; describe( "BulkEditorFilters", () => { beforeAll( () => { - registerStore(); + // Seeded with a carried-over overview selection, like initialize.js does, so the + // "Overview selection" filter is offered. + registerStore( { initialState: { query: { overviewIds: [ 5, 3 ], isOverviewFilterActive: true } } } ); } ); beforeEach( () => { @@ -14,6 +16,7 @@ describe( "BulkEditorFilters", () => { dispatch( STORE_NAME ).setStatuses( [] ); dispatch( STORE_NAME ).setNeedsImprovement( [] ); dispatch( STORE_NAME ).setActiveFieldSet( "search" ); + dispatch( STORE_NAME ).setOverviewFilterActive( false ); } ); it( "renders the Filters button without a count badge when nothing is applied", () => { @@ -81,6 +84,24 @@ describe( "BulkEditorFilters", () => { expect( screen.getByText( "1" ) ).toBeInTheDocument(); } ); + it( "offers the Overview selection filter while a carried-over selection exists", () => { + render( ); + + fireEvent.click( screen.getByRole( "button", { name: /Filters/ } ) ); + + expect( screen.getByRole( "checkbox", { name: "Overview selection" } ) ).toBeInTheDocument(); + } ); + + it( "activates the overview filter, updates the store and counts it in the badge", () => { + render( ); + + fireEvent.click( screen.getByRole( "button", { name: /Filters/ } ) ); + fireEvent.click( screen.getByRole( "checkbox", { name: "Overview selection" } ) ); + + expect( select( STORE_NAME ).selectIsOverviewFilterActive() ).toBe( true ); + expect( screen.getByText( "1" ) ).toBeInTheDocument(); + } ); + it( "returns focus to the trigger when the dialog is closed with Escape", () => { render( ); 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 e01782e384c..8953eda2f14 100644 --- a/packages/js/tests/bulk-editor/hooks/use-posts.test.js +++ b/packages/js/tests/bulk-editor/hooks/use-posts.test.js @@ -1,25 +1,34 @@ import { renderHook, waitFor } from "@testing-library/react"; -import { useSelect } from "@wordpress/data"; +import { useDispatch, useSelect } from "@wordpress/data"; import { usePosts } from "../../../src/bulk-editor/hooks/use-posts"; import { PAGE_SIZE } from "../../../src/bulk-editor/constants"; -jest.mock( "@wordpress/data", () => ( { useSelect: jest.fn() } ) ); +jest.mock( "@wordpress/data", () => ( { useSelect: jest.fn(), useDispatch: jest.fn() } ) ); + +// A stable fallback: a fresh [] per selector call would change identity on every render and +// make the hook's effect refetch forever. +const EMPTY = []; describe( "usePosts", () => { let dataProvider; let storeState; + let pruneSelection; beforeEach( () => { dataProvider = { getEndpoint: jest.fn( () => "https://example.com/wp-json/yoast/v1/bulk_editor/posts" ) }; - storeState = { search: "", page: 1, statuses: [], needsImprovement: [], activeFieldSet: "search" }; + storeState = { search: "", page: 1, statuses: [], needsImprovement: [], activeFieldSet: "search", overviewIds: [], isOverviewFilterActive: false }; // Resolve each useSelect call against our controllable store state. useSelect.mockImplementation( ( mapSelect ) => mapSelect( () => ( { selectSearch: () => storeState.search, selectPage: () => storeState.page, selectStatuses: () => storeState.statuses, - selectNeedsImprovement: () => storeState.needsImprovement ?? [], + selectNeedsImprovement: () => storeState.needsImprovement ?? EMPTY, selectActiveFieldSet: () => storeState.activeFieldSet ?? "search", + selectOverviewIds: () => storeState.overviewIds ?? EMPTY, + selectIsOverviewFilterActive: () => storeState.isOverviewFilterActive ?? false, } ) ) ); + pruneSelection = jest.fn(); + useDispatch.mockReturnValue( { pruneSelection } ); } ); it( "requests the posts endpoint with the content type, page size, page, search, statuses and needs-improvement", async() => { @@ -56,6 +65,75 @@ describe( "usePosts", () => { ); } ); + it( "requests only the carried-over posts while the overview filter is active", async() => { + storeState = { search: "", page: 1, statuses: [], overviewIds: [ 5, 3 ], isOverviewFilterActive: true }; + const remoteDataProvider = { fetchJson: jest.fn( () => Promise.resolve( { posts: [] } ) ) }; + + renderHook( () => usePosts( { dataProvider, remoteDataProvider, contentType: "page" } ) ); + + await waitFor( () => expect( remoteDataProvider.fetchJson ).toHaveBeenCalled() ); + + expect( remoteDataProvider.fetchJson ).toHaveBeenCalledWith( + "https://example.com/wp-json/yoast/v1/bulk_editor/posts", + expect.objectContaining( { include: [ "5", "3" ] } ), + expect.objectContaining( { signal: expect.anything() } ) + ); + } ); + + it( "does not send the carried-over posts while the overview filter is inactive", async() => { + storeState = { search: "", page: 1, statuses: [], overviewIds: [ 5, 3 ], isOverviewFilterActive: false }; + const remoteDataProvider = { fetchJson: jest.fn( () => Promise.resolve( { posts: [] } ) ) }; + + renderHook( () => usePosts( { dataProvider, remoteDataProvider, contentType: "page" } ) ); + + await waitFor( () => expect( remoteDataProvider.fetchJson ).toHaveBeenCalled() ); + + expect( remoteDataProvider.fetchJson ).toHaveBeenCalledWith( + "https://example.com/wp-json/yoast/v1/bulk_editor/posts", + expect.not.objectContaining( { include: expect.anything() } ), + expect.objectContaining( { signal: expect.anything() } ) + ); + } ); + + it( "prunes the selection to the editable listed rows while the overview filter is active", async() => { + storeState = { search: "", page: 1, statuses: [], overviewIds: [ 5, 3, 99 ], isOverviewFilterActive: true }; + const remoteDataProvider = { + // Post 99 is not listed at all; post 3 is listed but not editable, so its checkbox is disabled. + fetchJson: jest.fn( () => Promise.resolve( { posts: [ + { id: 5, title: "Listed", editable: true }, + { id: 3, title: "Locked", editable: false }, + ] } ) ), + }; + + const { result } = renderHook( () => usePosts( { dataProvider, remoteDataProvider, contentType: "page" } ) ); + + await waitFor( () => expect( result.current.isPending ).toBe( false ) ); + + expect( pruneSelection ).toHaveBeenCalledWith( [ 5 ] ); + } ); + + it( "does not prune the selection while the overview filter is inactive", async() => { + storeState = { search: "", page: 1, statuses: [], overviewIds: [ 5, 3 ], isOverviewFilterActive: false }; + const remoteDataProvider = { fetchJson: jest.fn( () => Promise.resolve( { posts: [ { id: 5, title: "Listed", editable: true } ] } ) ) }; + + const { result } = renderHook( () => usePosts( { dataProvider, remoteDataProvider, contentType: "page" } ) ); + + await waitFor( () => expect( result.current.isPending ).toBe( false ) ); + + expect( pruneSelection ).not.toHaveBeenCalled(); + } ); + + it( "does not prune the selection when the request fails", async() => { + storeState = { search: "", page: 1, statuses: [], overviewIds: [ 5, 3 ], isOverviewFilterActive: true }; + const remoteDataProvider = { fetchJson: jest.fn( () => Promise.reject( new Error( "boom" ) ) ) }; + + const { result } = renderHook( () => usePosts( { dataProvider, remoteDataProvider, contentType: "page" } ) ); + + await waitFor( () => expect( result.current.isPending ).toBe( false ) ); + + expect( pruneSelection ).not.toHaveBeenCalled(); + } ); + it( "maps the snake_case API rows to camelCase bulk editor rows and exposes the totals", async() => { const remoteDataProvider = { /* eslint-disable camelcase -- The REST endpoint returns snake_case fields. */ diff --git a/packages/js/tests/bulk-editor/initialize.test.js b/packages/js/tests/bulk-editor/initialize.test.js index 17862417321..552baac94fa 100644 --- a/packages/js/tests/bulk-editor/initialize.test.js +++ b/packages/js/tests/bulk-editor/initialize.test.js @@ -90,6 +90,9 @@ describe( "bulk editor initialize", () => { connectUrl: null, learnMoreUrl: "", }, + activeContentType: "", + selection: { selectedIds: [], preselectedTotal: 0 }, + query: { overviewIds: [], isOverviewFilterActive: false }, }, } ); expect( mockFixScrolling ).toHaveBeenCalledTimes( 1 ); @@ -97,6 +100,89 @@ describe( "bulk editor initialize", () => { expect( mockRender ).toHaveBeenCalledTimes( 1 ); } ); + test( "should seed the store with the selection carried over from the WP admin overview", () => { + const root = document.createElement( "div" ); + root.id = ROOT_ID; + document.body.appendChild( root ); + window.wpseoBulkEditorData.initialSelection = { contentType: "page", postIds: [ 5, 3 ], selectedCount: 25 }; + + jest.isolateModules( () => { + require( "../../src/bulk-editor/initialize" ); + } ); + + expect( mockRegisterStore ).toHaveBeenCalledWith( { + initialState: { + linkParams: { foo: "bar" }, + myyoastConnection: { + isAvailable: false, + canConnect: false, + connectUrl: null, + learnMoreUrl: "", + }, + activeContentType: "page", + selection: { selectedIds: [ 5, 3 ], preselectedTotal: 25 }, + query: { overviewIds: [ 5, 3 ], isOverviewFilterActive: true }, + }, + } ); + } ); + + describe( "getPreselectionState", () => { + /** + * Requires the module in isolation and returns the getPreselectionState export. + * + * @returns {Function} The getPreselectionState function. + */ + const requireGetPreselectionState = () => { + let getPreselectionState; + jest.isolateModules( () => { + ( { getPreselectionState } = require( "../../src/bulk-editor/initialize" ) ); + } ); + return getPreselectionState; + }; + + test( "should return an empty seed for a missing or malformed payload", () => { + const getPreselectionState = requireGetPreselectionState(); + + const emptySeed = { + activeContentType: "", + selection: { selectedIds: [], preselectedTotal: 0 }, + query: { overviewIds: [], isOverviewFilterActive: false }, + }; + expect( getPreselectionState() ).toEqual( emptySeed ); + expect( getPreselectionState( { contentType: 7, postIds: "5,3", selectedCount: 25 } ) ).toEqual( emptySeed ); + } ); + + test( "should drop non-numeric and non-positive ids and never let the total undercut them", () => { + const getPreselectionState = requireGetPreselectionState(); + + expect( getPreselectionState( { contentType: "post", postIds: [ "5", 3, 0, -2, "junk" ], selectedCount: 1 } ) ).toEqual( { + activeContentType: "post", + selection: { selectedIds: [ 5, 3 ], preselectedTotal: 2 }, + query: { overviewIds: [ 5, 3 ], isOverviewFilterActive: true }, + } ); + } ); + + test( "should cap the seeded ids at the batch size", () => { + const getPreselectionState = requireGetPreselectionState(); + + const postIds = Array.from( { length: 25 }, ( _, index ) => index + 1 ); + const { selection } = getPreselectionState( { contentType: "post", postIds, selectedCount: 25 } ); + + expect( selection.selectedIds ).toHaveLength( 20 ); + expect( selection.preselectedTotal ).toBe( 25 ); + } ); + + test( "should not report a carried-over total without any usable ids", () => { + const getPreselectionState = requireGetPreselectionState(); + + expect( getPreselectionState( { contentType: "post", postIds: [], selectedCount: 25 } ) ).toEqual( { + activeContentType: "post", + selection: { selectedIds: [], preselectedTotal: 0 }, + query: { overviewIds: [], isOverviewFilterActive: false }, + } ); + } ); + } ); + test( "should construct the remote data provider with the REST nonce header", () => { const root = document.createElement( "div" ); root.id = ROOT_ID; diff --git a/packages/js/tests/bulk-editor/overview-exclusion-notice.test.js b/packages/js/tests/bulk-editor/overview-exclusion-notice.test.js new file mode 100644 index 00000000000..81360b9063f --- /dev/null +++ b/packages/js/tests/bulk-editor/overview-exclusion-notice.test.js @@ -0,0 +1,98 @@ +import { SlotFillProvider } from "@wordpress/components"; +import { OverviewExclusionNotice } from "../../src/bulk-editor/components/overview-exclusion-notice"; +import { BulkEditorContent } from "../../src/bulk-editor/components/bulk-editor-content"; +import { DataProvider } from "../../src/bulk-editor/services"; +import registerStore from "../../src/bulk-editor/store"; +import { fireEvent, render, screen, within } from "../test-utils"; + +const NOTICE_TEXT = "Your selection has been updated. Private, password-protected, or non-indexed items can't be bulk edited and were excluded."; +const TRUNCATION_TEXT = "Only the first 20 items from your selection were carried over. The bulk editor supports up to 20 items at a time."; + +describe( "OverviewExclusionNotice", () => { + it( "explains that carried-over items were excluded", () => { + render( ); + + expect( screen.getByText( NOTICE_TEXT ) ).toBeInTheDocument(); + } ); + + it( "calls onDismiss when the dismiss button is clicked", () => { + const onDismiss = jest.fn(); + render( ); + + fireEvent.click( screen.getByRole( "button", { name: "Dismiss" } ) ); + + expect( onDismiss ).toHaveBeenCalledTimes( 1 ); + } ); + + it( "renders nothing while nothing was excluded", () => { + const { container } = render( ); + + expect( container ).toBeEmptyDOMElement(); + } ); +} ); + +describe( "BulkEditorContent with a truncated and pruned carried-over selection", () => { + const dataProvider = new DataProvider( { + contentTypes: [ { name: "post", label: "Posts", singularLabel: "Post" } ], + endpoints: { posts: "https://example.com/wp-json/yoast/v1/bulk_editor/posts" }, + links: {}, + } ); + // The data layer is covered by use-posts.test.js; the request stays pending here. + const remoteDataProvider = { fetchJson: jest.fn( () => new Promise( () => {} ) ) }; + + // The store registers once for the whole file: the WP data registry is global, so a second register would + // clash. Seeded as if 25 items were selected on the overview and pruning dropped some of the carried 20. + beforeAll( () => { + registerStore( { + initialState: { + activeContentType: "post", + selection: { + selectedIds: Array.from( { length: 18 }, ( _, index ) => index + 1 ), + preselectedTotal: 25, + hasExcludedPreselected: true, + }, + }, + } ); + } ); + + const renderContent = () => render( + + + + ); + + it( "shows the truncation and exclusion notices together, dismissible independently", () => { + const { container } = renderContent(); + + const truncation = screen.getByText( TRUNCATION_TEXT ); + const exclusion = screen.getByText( NOTICE_TEXT ); + expect( truncation ).toBeInTheDocument(); + expect( exclusion ).toBeInTheDocument(); + // Both render inside the expanded bulk-actions row, the truncation notice first. + expect( exclusion.closest( "tr" ) ).toBe( truncation.closest( "tr" ) ); + expect( truncation.closest( "tr" ) ).toHaveAttribute( "aria-hidden", "false" ); + // eslint-disable-next-line no-bitwise -- compareDocumentPosition returns a bitmask. + expect( truncation.compareDocumentPosition( exclusion ) & Node.DOCUMENT_POSITION_FOLLOWING ).toBeTruthy(); + + fireEvent.click( within( exclusion.closest( "[role='status']" ) ).getByRole( "button", { name: "Dismiss" } ) ); + + // The exclusion notice is gone; the truncation notice keeps the row expanded. + expect( screen.queryByText( NOTICE_TEXT ) ).not.toBeInTheDocument(); + expect( screen.getByText( TRUNCATION_TEXT ) ).toBeInTheDocument(); + expect( screen.getByText( TRUNCATION_TEXT ).closest( "tr" ) ).toHaveAttribute( "aria-hidden", "false" ); + + fireEvent.click( screen.getByRole( "button", { name: "Dismiss" } ) ); + + // Nothing else occupies the band here (AI is disabled, no edits), so dismissing both collapses the row. + expect( screen.queryByText( TRUNCATION_TEXT ) ).not.toBeInTheDocument(); + container.querySelectorAll( "tr[aria-hidden]" ).forEach( ( row ) => { + expect( row ).toHaveAttribute( "aria-hidden", "true" ); + } ); + } ); +} ); diff --git a/packages/js/tests/bulk-editor/overview-selection-notice.test.js b/packages/js/tests/bulk-editor/overview-selection-notice.test.js new file mode 100644 index 00000000000..eb2ba6a3298 --- /dev/null +++ b/packages/js/tests/bulk-editor/overview-selection-notice.test.js @@ -0,0 +1,84 @@ +import { SlotFillProvider } from "@wordpress/components"; +import { OverviewSelectionNotice } from "../../src/bulk-editor/components/overview-selection-notice"; +import { BulkEditorContent } from "../../src/bulk-editor/components/bulk-editor-content"; +import { DataProvider } from "../../src/bulk-editor/services"; +import registerStore from "../../src/bulk-editor/store"; +import { fireEvent, render, screen } from "../test-utils"; + +const NOTICE_TEXT = "Only the first 20 items from your selection were carried over. The bulk editor supports up to 20 items at a time."; + +describe( "OverviewSelectionNotice", () => { + it( "explains that only the first batch of the overview selection is selected", () => { + render( ); + + expect( screen.getByText( NOTICE_TEXT ) ).toBeInTheDocument(); + } ); + + it( "calls onDismiss when the dismiss button is clicked", () => { + const onDismiss = jest.fn(); + render( ); + + fireEvent.click( screen.getByRole( "button", { name: "Dismiss" } ) ); + + expect( onDismiss ).toHaveBeenCalledTimes( 1 ); + } ); + + it( "renders nothing while the whole selection fits the batch", () => { + const { container } = render( ); + + expect( container ).toBeEmptyDOMElement(); + } ); +} ); + +describe( "BulkEditorContent with a carried-over overview selection", () => { + const dataProvider = new DataProvider( { + contentTypes: [ { name: "post", label: "Posts", singularLabel: "Post" } ], + endpoints: { posts: "https://example.com/wp-json/yoast/v1/bulk_editor/posts" }, + links: {}, + } ); + // The data layer is covered by use-posts.test.js; the request stays pending here. + const remoteDataProvider = { fetchJson: jest.fn( () => new Promise( () => {} ) ) }; + + // The store registers once for the whole file: the WP data registry is global, so a second register would + // clash. Seeded like initialize.js does for a selection carried over from the WP admin overview. + beforeAll( () => { + registerStore( { + initialState: { + activeContentType: "post", + selection: { selectedIds: Array.from( { length: 20 }, ( _, index ) => index + 1 ), preselectedTotal: 25 }, + }, + } ); + } ); + + const renderContent = () => render( + + + + ); + + it( "shows the notice inside the expanded bulk-actions row and hides it on dismiss", () => { + const { container } = renderContent(); + + const notice = screen.getByText( NOTICE_TEXT ); + expect( notice ).toBeInTheDocument(); + // The notice renders inside the table's bulk-actions row (between the Select toolbar and the band), + // which the notice itself keeps expanded. + const bandRow = notice.closest( "tr" ); + expect( bandRow ).not.toBeNull(); + expect( bandRow ).toHaveAttribute( "aria-hidden", "false" ); + + fireEvent.click( screen.getByRole( "button", { name: "Dismiss" } ) ); + + expect( screen.queryByText( NOTICE_TEXT ) ).not.toBeInTheDocument(); + // Nothing else occupies the band here (AI is disabled, no edits), so dismissing collapses the row. + container.querySelectorAll( "tr[aria-hidden]" ).forEach( ( row ) => { + expect( row ).toHaveAttribute( "aria-hidden", "true" ); + } ); + } ); +} ); diff --git a/packages/js/tests/bulk-editor/store/query.test.js b/packages/js/tests/bulk-editor/store/query.test.js index 1b45a6842b0..9d2178832ec 100644 --- a/packages/js/tests/bulk-editor/store/query.test.js +++ b/packages/js/tests/bulk-editor/store/query.test.js @@ -2,8 +2,8 @@ import { activeContentTypeActions } from "../../../src/bulk-editor/store/active- import reducer, { createInitialQueryState, queryActions, querySelectors } from "../../../src/bulk-editor/store/query"; describe( "query slice", () => { - it( "defaults to an empty search on the first page with no status or needs-improvement filter", () => { - expect( createInitialQueryState() ).toEqual( { search: "", page: 1, statuses: [], needsImprovement: [] } ); + it( "defaults to an empty search on the first page with no status, needs-improvement or overview filter", () => { + expect( createInitialQueryState() ).toEqual( { search: "", page: 1, statuses: [], needsImprovement: [], overviewIds: [], isOverviewFilterActive: false } ); } ); it( "sets the search term and resets to the first page", () => { @@ -30,10 +30,30 @@ describe( "query slice", () => { expect( state ).toEqual( { search: "seo", page: 1, needsImprovement: [ "title", "description" ] } ); } ); - it( "resets to the first page when the content type changes", () => { - const state = reducer( { search: "seo", page: 9 }, activeContentTypeActions.setActiveContentType( "page" ) ); + it( "resets to the first page and drops the overview selection when the content type changes", () => { + const state = reducer( + { search: "seo", page: 9, overviewIds: [ 5, 3 ], isOverviewFilterActive: true }, + activeContentTypeActions.setActiveContentType( "page" ) + ); - expect( state ).toEqual( { search: "seo", page: 1 } ); + expect( state ).toEqual( { search: "seo", page: 1, overviewIds: [], isOverviewFilterActive: false } ); + } ); + + it( "toggles the overview filter and resets to the first page", () => { + let state = reducer( { page: 4, overviewIds: [ 5, 3 ], isOverviewFilterActive: true }, queryActions.setOverviewFilterActive( false ) ); + expect( state ).toEqual( { page: 1, overviewIds: [ 5, 3 ], isOverviewFilterActive: false } ); + + state = reducer( { ...state, page: 2 }, queryActions.setOverviewFilterActive( true ) ); + expect( state ).toEqual( { page: 1, overviewIds: [ 5, 3 ], isOverviewFilterActive: true } ); + } ); + + it( "selects the overview selection state, defaulting when missing", () => { + const state = { query: { overviewIds: [ 5, 3 ], isOverviewFilterActive: true } }; + + expect( querySelectors.selectOverviewIds( state ) ).toEqual( [ 5, 3 ] ); + expect( querySelectors.selectIsOverviewFilterActive( state ) ).toBe( true ); + expect( querySelectors.selectOverviewIds( {} ) ).toEqual( [] ); + expect( querySelectors.selectIsOverviewFilterActive( {} ) ).toBe( false ); } ); it( "selects the search term, page, statuses, needs-improvement and query from the store state", () => { @@ -51,6 +71,6 @@ describe( "query slice", () => { expect( querySelectors.selectPage( {} ) ).toBe( 1 ); expect( querySelectors.selectStatuses( {} ) ).toEqual( [] ); expect( querySelectors.selectNeedsImprovement( {} ) ).toEqual( [] ); - expect( querySelectors.selectQuery( {} ) ).toEqual( { search: "", page: 1, statuses: [], needsImprovement: [] } ); + expect( querySelectors.selectQuery( {} ) ).toEqual( { search: "", page: 1, statuses: [], needsImprovement: [], overviewIds: [], isOverviewFilterActive: false } ); } ); } ); diff --git a/packages/js/tests/bulk-editor/store/selection.test.js b/packages/js/tests/bulk-editor/store/selection.test.js index 9cc052550e5..e82a47ad24a 100644 --- a/packages/js/tests/bulk-editor/store/selection.test.js +++ b/packages/js/tests/bulk-editor/store/selection.test.js @@ -4,8 +4,8 @@ import reducer, { createInitialSelectionState, selectionActions, selectionSelect import { queryActions } from "../../../src/bulk-editor/store/query"; describe( "selection slice", () => { - it( "defaults to no rows selected", () => { - expect( createInitialSelectionState() ).toEqual( { selectedIds: [] } ); + it( "defaults to no rows selected, no carried-over selection and no exclusions", () => { + expect( createInitialSelectionState() ).toEqual( { selectedIds: [], preselectedTotal: 0, hasExcludedPreselected: false } ); } ); it( "adds a row to the selection when toggled on", () => { @@ -39,6 +39,47 @@ describe( "selection slice", () => { expect( state.selectedIds ).toEqual( [] ); } ); + it( "prunes selected ids missing from the given selectable ids, keeping the carried-over selection total and flagging the exclusion", () => { + const state = reducer( { selectedIds: [ 7, 9, 11 ], preselectedTotal: 25 }, selectionActions.pruneSelection( [ 9, 11, 13 ] ) ); + + expect( state ).toEqual( { selectedIds: [ 9, 11 ], preselectedTotal: 25, hasExcludedPreselected: true } ); + } ); + + it( "keeps the selection intact and unflagged when all selected ids are selectable", () => { + const state = reducer( + { selectedIds: [ 7, 9 ], preselectedTotal: 25, hasExcludedPreselected: false }, + selectionActions.pruneSelection( [ 7, 9, 11 ] ) + ); + + expect( state ).toEqual( { selectedIds: [ 7, 9 ], preselectedTotal: 25, hasExcludedPreselected: false } ); + } ); + + it( "clears the exclusion flag on dismissExclusionNotice, keeping the selection and the carried-over total", () => { + const state = reducer( + { selectedIds: [ 9, 11 ], preselectedTotal: 25, hasExcludedPreselected: true }, + selectionActions.dismissExclusionNotice() + ); + + expect( state ).toEqual( { selectedIds: [ 9, 11 ], preselectedTotal: 25, hasExcludedPreselected: false } ); + } ); + + it( "clears the exclusion flag on selectAll", () => { + const state = reducer( { selectedIds: [ 9 ], preselectedTotal: 25, hasExcludedPreselected: true }, selectionActions.selectAll( [ 7, 9 ] ) ); + + expect( state ).toEqual( { selectedIds: [ 7, 9 ], preselectedTotal: 0, hasExcludedPreselected: false } ); + } ); + + it( "clears the exclusion flag when the shown result set changes", () => { + const state = reducer( { selectedIds: [ 9 ], preselectedTotal: 25, hasExcludedPreselected: true }, queryActions.setPage( 2 ) ); + + expect( state ).toEqual( createInitialSelectionState() ); + } ); + + it( "selects the exclusion flag, defaulting to false when missing", () => { + expect( selectionSelectors.selectHasExcludedPreselected( { selection: { hasExcludedPreselected: true } } ) ).toBe( true ); + expect( selectionSelectors.selectHasExcludedPreselected( {} ) ).toBe( false ); + } ); + it( "clears the selection when the status filter changes", () => { const state = reducer( { selectedIds: [ 7, 9 ] }, queryActions.setStatuses( [ "draft" ] ) ); @@ -79,4 +120,45 @@ describe( "selection slice", () => { expect( selectionSelectors.selectSelectedIds( { selection: { selectedIds: [ 7 ] } } ) ).toEqual( [ 7 ] ); expect( selectionSelectors.selectSelectedIds( {} ) ).toEqual( [] ); } ); + + it( "clears the carried-over selection total on dismissPreselectionNotice", () => { + const state = reducer( { selectedIds: [ 7, 9 ], preselectedTotal: 25 }, selectionActions.dismissPreselectionNotice() ); + + expect( state ).toEqual( { selectedIds: [ 7, 9 ], preselectedTotal: 0 } ); + } ); + + it( "clears the carried-over selection total on selectAll", () => { + const state = reducer( { selectedIds: [ 7 ], preselectedTotal: 25 }, selectionActions.selectAll( [ 7, 9 ] ) ); + + expect( state ).toEqual( { selectedIds: [ 7, 9 ], preselectedTotal: 0, hasExcludedPreselected: false } ); + } ); + + it( "clears the carried-over selection total on deselectAll", () => { + const state = reducer( { selectedIds: [ 7 ], preselectedTotal: 25 }, selectionActions.deselectAll() ); + + expect( state ).toEqual( createInitialSelectionState() ); + } ); + + it( "keeps the carried-over selection total when a single row is toggled", () => { + const state = reducer( { selectedIds: [ 7 ], preselectedTotal: 25 }, selectionActions.toggleRow( 9 ) ); + + expect( state ).toEqual( { selectedIds: [ 7, 9 ], preselectedTotal: 25 } ); + } ); + + it( "clears the carried-over selection total when the shown result set changes", () => { + const state = reducer( { selectedIds: [ 7, 9 ], preselectedTotal: 25 }, queryActions.setPage( 2 ) ); + + expect( state ).toEqual( createInitialSelectionState() ); + } ); + + it( "clears the selection when the overview filter is toggled", () => { + const state = reducer( { selectedIds: [ 7, 9 ], preselectedTotal: 25 }, queryActions.setOverviewFilterActive( false ) ); + + expect( state ).toEqual( createInitialSelectionState() ); + } ); + + it( "selects the carried-over selection total, defaulting to 0 when missing", () => { + expect( selectionSelectors.selectPreselectedTotal( { selection: { preselectedTotal: 25 } } ) ).toBe( 25 ); + expect( selectionSelectors.selectPreselectedTotal( {} ) ).toBe( 0 ); + } ); } ); diff --git a/src/bulk-editor/domain/posts/posts-query.php b/src/bulk-editor/domain/posts/posts-query.php index a3b6f70c541..0ab8b8bf6cc 100644 --- a/src/bulk-editor/domain/posts/posts-query.php +++ b/src/bulk-editor/domain/posts/posts-query.php @@ -69,6 +69,13 @@ class Posts_Query { */ private $scores_enabled; + /** + * The post IDs to limit posts to, or an empty array for no restriction. + * + * @var array + */ + private $include_ids; + /** * The constructor. * @@ -80,6 +87,7 @@ class Posts_Query { * @param int|null $author_id The author to limit posts to, or null for no author restriction. * @param array $needs_improvement The needs-improvement fields to filter by (a field matches when empty, or — for search fields with SEO analysis enabled — when its per-field score needs improvement), or an empty array for no such filter. * @param bool $scores_enabled Whether the per-field scores may back the needs-improvement filter. + * @param array $include_ids The post IDs to limit posts to, or an empty array for no restriction. */ public function __construct( string $content_type, @@ -89,7 +97,8 @@ public function __construct( array $statuses, ?int $author_id = null, array $needs_improvement = [], - bool $scores_enabled = true + bool $scores_enabled = true, + array $include_ids = [] ) { $this->content_type = $content_type; $this->page = $page; @@ -99,6 +108,7 @@ public function __construct( $this->author_id = $author_id; $this->needs_improvement = $needs_improvement; $this->scores_enabled = $scores_enabled; + $this->include_ids = $include_ids; } /** @@ -191,6 +201,24 @@ public function has_author_filter(): bool { return $this->author_id !== null; } + /** + * Returns the post IDs to limit posts to. + * + * @return array The post IDs, or an empty array for no restriction. + */ + public function get_include_ids(): array { + return $this->include_ids; + } + + /** + * Returns whether posts are limited to specific post IDs. + * + * @return bool Whether a post ID restriction is set. + */ + public function has_include(): bool { + return $this->include_ids !== []; + } + /** * Returns the zero-based offset of the requested page. * diff --git a/src/bulk-editor/infrastructure/posts/indexable-posts-collector.php b/src/bulk-editor/infrastructure/posts/indexable-posts-collector.php index c3c5675a076..79497d69785 100644 --- a/src/bulk-editor/infrastructure/posts/indexable-posts-collector.php +++ b/src/bulk-editor/infrastructure/posts/indexable-posts-collector.php @@ -153,6 +153,10 @@ private function build_query( Posts_Query $query ): ORM { $builder->where( 'author_id', $query->get_author_id() ); } + if ( $query->has_include() ) { + $builder->where_in( 'object_id', $query->get_include_ids() ); + } + if ( $query->has_search() ) { $this->apply_search( $builder, $query->get_search() ); } 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 fd0c1b8da2c..12fe1d00459 100644 --- a/src/bulk-editor/infrastructure/posts/post-meta-posts-collector.php +++ b/src/bulk-editor/infrastructure/posts/post-meta-posts-collector.php @@ -147,7 +147,7 @@ protected function run_query( Posts_Query $query ): WP_Query { * * @param Posts_Query $query The query describing the page to collect. * - * @return array> The WP_Query arguments. + * @return array|array> The WP_Query arguments. */ private function build_query_args( Posts_Query $query ): array { $args = [ @@ -170,6 +170,10 @@ private function build_query_args( Posts_Query $query ): array { $args['author'] = $query->get_author_id(); } + if ( $query->has_include() ) { + $args['post__in'] = $query->get_include_ids(); + } + return $args; } diff --git a/src/bulk-editor/user-interface/bulk-editor-integration.php b/src/bulk-editor/user-interface/bulk-editor-integration.php index 4c745c04a70..0dc856929a0 100644 --- a/src/bulk-editor/user-interface/bulk-editor-integration.php +++ b/src/bulk-editor/user-interface/bulk-editor-integration.php @@ -6,6 +6,7 @@ use WPSEO_Admin_Asset_Manager; 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; use Yoast\WP\SEO\Bulk_Editor\Infrastructure\Nonces\Nonce_Repository; use Yoast\WP\SEO\Conditionals\Admin_Conditional; use Yoast\WP\SEO\General\User_Interface\General_Page_Integration; @@ -31,6 +32,21 @@ class Bulk_Editor_Integration implements Integration_Interface { */ public const ASSETS_NAME = 'bulk-editor-page'; + /** + * The URL parameter carrying the content type to preselect. + */ + public const CONTENT_TYPE_PARAM = 'content_type'; + + /** + * The URL parameter carrying the post IDs to preselect, comma-separated. + */ + public const POST_IDS_PARAM = 'post_ids'; + + /** + * The URL parameter carrying how many posts were selected on the overview the user came from. + */ + public const SELECTED_COUNT_PARAM = 'selected_count'; + /** * Holds the WPSEO_Admin_Asset_Manager. * @@ -155,6 +171,7 @@ public function register_hooks() { if ( $this->current_page_helper->get_current_yoast_seo_page() === self::PAGE ) { \add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_assets' ] ); \add_action( 'in_admin_header', [ $this, 'remove_notices' ], \PHP_INT_MAX ); + \add_filter( 'removable_query_args', [ $this, 'add_removable_query_args' ] ); } } @@ -220,8 +237,10 @@ public function enqueue_assets() { * @return array>> The script data. */ public function get_script_data() { + $content_types = $this->content_types_repository->get_content_types(); + return [ - 'contentTypes' => $this->content_types_repository->get_content_types(), + 'contentTypes' => $content_types, 'endpoints' => $this->endpoints_repository->get_all_endpoints()->to_array(), // These must stay server-generated URLs: the bulk editor assigns them to window.location.href for its // "Back to Tools" / logo navigation. If a link ever derives from request input, validate it with @@ -244,10 +263,88 @@ public function get_script_data() { // Re-scoring only runs when SEO analysis is enabled, matching the post editor. 'keywordAnalysisActive' => $this->options_helper->get( 'keyword_analysis_active' ) === true, ], + 'initialSelection' => $this->get_initial_selection( $content_types ), 'myyoastConnection' => $this->myyoast_connection_data_presenter->present(), ]; } + /** + * Returns the selection carried over from a post overview bulk action, if any. + * + * The parameters only decide which rows start out selected in the app; the REST endpoints + * enforce the actual per-post edit access when anything is saved. + * + * @param array> $content_types The available content types. + * + * @return array> The content type, post IDs and overview selection count. + */ + private function get_initial_selection( array $content_types ): array { + $initial_selection = [ + 'contentType' => '', + 'postIds' => [], + 'selectedCount' => 0, + ]; + + // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Reason: read-only display state, no action is taken. + if ( ! isset( $_GET[ self::CONTENT_TYPE_PARAM ] ) || ! \is_string( $_GET[ self::CONTENT_TYPE_PARAM ] ) ) { + return $initial_selection; + } + + $content_type = \sanitize_text_field( \wp_unslash( $_GET[ self::CONTENT_TYPE_PARAM ] ) ); + if ( ! \in_array( $content_type, \array_column( $content_types, 'name' ), true ) ) { + return $initial_selection; + } + $initial_selection['contentType'] = $content_type; + + if ( isset( $_GET[ self::POST_IDS_PARAM ] ) && \is_string( $_GET[ self::POST_IDS_PARAM ] ) ) { + $post_ids = \explode( ',', \sanitize_text_field( \wp_unslash( $_GET[ self::POST_IDS_PARAM ] ) ) ); + $post_ids = \array_values( + \array_unique( + \array_filter( + \array_map( 'intval', $post_ids ), + static function ( $id ) { + return $id > 0; + }, + ), + ), + ); + $post_ids = \array_slice( $post_ids, 0, Batch_Limit::MAX_ITEMS ); + + $initial_selection['postIds'] = $post_ids; + $initial_selection['selectedCount'] = \count( $post_ids ); + } + + if ( + $initial_selection['postIds'] !== [] + && isset( $_GET[ self::SELECTED_COUNT_PARAM ] ) + && \is_string( $_GET[ self::SELECTED_COUNT_PARAM ] ) + ) { + // The count can only grow beyond the carried IDs, never shrink below them. + $initial_selection['selectedCount'] = \max( + $initial_selection['selectedCount'], + \absint( \wp_unslash( $_GET[ self::SELECTED_COUNT_PARAM ] ) ), + ); + } + // phpcs:enable WordPress.Security.NonceVerification.Recommended + + return $initial_selection; + } + + /** + * Registers the carried-over selection parameters as removable, so WordPress cleans them from the + * address bar once the page has picked them up. + * + * @param array $removable_query_args The removable query args. + * + * @return array The removable query args. + */ + public function add_removable_query_args( $removable_query_args ) { + $removable_query_args[] = self::POST_IDS_PARAM; + $removable_query_args[] = self::SELECTED_COUNT_PARAM; + + return $removable_query_args; + } + /** * Removes all current WP notices. * diff --git a/src/bulk-editor/user-interface/posts-overview-bulk-actions-integration.php b/src/bulk-editor/user-interface/posts-overview-bulk-actions-integration.php new file mode 100644 index 00000000000..97207887b1c --- /dev/null +++ b/src/bulk-editor/user-interface/posts-overview-bulk-actions-integration.php @@ -0,0 +1,161 @@ +content_types_repository = $content_types_repository; + $this->current_page_helper = $current_page_helper; + } + + /** + * Returns the conditionals based on which this loadable should be active. + * + * @return array The conditionals. + */ + public static function get_conditionals() { + return [ Admin_Conditional::class ]; + } + + /** + * Initializes the integration. + * + * This is the place to register hooks and filters. + * + * @return void + */ + public function register_hooks() { + // The supported post types are only known once post types are registered, so defer collecting them. + \add_action( 'admin_init', [ $this, 'register_bulk_actions' ] ); + } + + /** + * Registers the bulk action on the overviews of the post types the bulk editor supports. + * + * @return void + */ + public function register_bulk_actions() { + // The same capability that gates access to the bulk editor page itself. + if ( ! \current_user_can( 'wpseo_manage_options' ) ) { + return; + } + + foreach ( $this->content_types_repository->get_content_types() as $content_type ) { + \add_filter( 'bulk_actions-edit-' . $content_type['name'], [ $this, 'add_bulk_action' ] ); + \add_filter( 'handle_bulk_actions-edit-' . $content_type['name'], [ $this, 'handle_bulk_action' ], 10, 3 ); + } + } + + /** + * Adds the bulk editor entry to the bulk-actions dropdown. + * + * @param array> $actions The bulk actions. + * + * @return array> The bulk actions. + */ + public function add_bulk_action( $actions ) { + // Trashed posts are not listed in the bulk editor, so the trash view does not get the entry. + if ( $this->current_page_helper->is_trash_overview() ) { + return $actions; + } + + // The arrow signals that this action navigates to another page instead of processing in place. + // VS15 (U+FE0E) forces text presentation, so wp-admin's emoji script leaves the glyph alone; + // RTL admins get the mirrored glyph. + $arrow = ( \is_rtl() ) ? "\u{2196}\u{FE0E}" : "\u{2197}\u{FE0E}"; + + // A nested array renders as an optgroup (since WP 5.6): the default actions stay first, followed + // by a visually separated "Yoast SEO" group holding the entry. + $actions['Yoast SEO'] = [ + self::BULK_ACTION => \__( 'Bulk edit', 'wordpress-seo' ) . ' ' . $arrow, + ]; + + return $actions; + } + + /** + * Handles the bulk action by sending the user to the bulk editor page with the selection carried over. + * + * @param string $redirect_url The URL the overview would redirect to. + * @param string $action The bulk action being handled. + * @param array $post_ids The selected post IDs. + * + * @return string The URL to redirect to. + */ + public function handle_bulk_action( $redirect_url, $action, $post_ids ) { + if ( $action !== self::BULK_ACTION ) { + return $redirect_url; + } + + // The filter only fires on the overview of the post type it was registered for, so the + // current screen carries the content type to preselect. + $screen = \get_current_screen(); + if ( $screen === null || $screen->post_type === '' ) { + return $redirect_url; + } + + $args = [ + 'page' => Bulk_Editor_Integration::PAGE, + Bulk_Editor_Integration::CONTENT_TYPE_PARAM => $screen->post_type, + ]; + + $ids = \array_values( + \array_unique( + \array_filter( + \array_map( 'intval', (array) $post_ids ), + static function ( $id ) { + return $id > 0; + }, + ), + ), + ); + + if ( $ids !== [] ) { + // Only the first batch can end up selected; carrying more would only risk hitting URL length limits. + $args[ Bulk_Editor_Integration::POST_IDS_PARAM ] = \implode( ',', \array_slice( $ids, 0, Batch_Limit::MAX_ITEMS ) ); + $args[ Bulk_Editor_Integration::SELECTED_COUNT_PARAM ] = \count( $ids ); + } + + return \add_query_arg( $args, \admin_url( 'admin.php' ) ); + } +} diff --git a/src/bulk-editor/user-interface/posts-route.php b/src/bulk-editor/user-interface/posts-route.php index 3d91c0d3231..d87f27f1728 100644 --- a/src/bulk-editor/user-interface/posts-route.php +++ b/src/bulk-editor/user-interface/posts-route.php @@ -172,6 +172,17 @@ public function register_routes() { ], 'description' => 'The fields to filter posts by; a field matches when it is empty, or (for search fields with SEO analysis enabled) when its score needs improvement.', ], + 'include' => [ + 'required' => false, + 'type' => 'array', + 'default' => [], + 'maxItems' => self::MAX_PER_PAGE, + 'items' => [ + 'type' => 'integer', + 'minimum' => 1, + ], + 'description' => 'Limits the posts to these post IDs, e.g. a selection carried over from the posts overview.', + ], ], 'callback' => [ $this, 'get_posts' ], 'permission_callback' => [ $this, 'check_permissions' ], @@ -214,6 +225,9 @@ public function get_posts( WP_REST_Request $request ) { // the filter falls back to the empty-field check. $scores_enabled = $this->options_helper->get( 'keyword_analysis_active' ) === true; + // The schema already coerces the items to positive integers; deduplicate on top of that. + $include = \array_values( \array_unique( \array_map( 'intval', (array) $request->get_param( 'include' ) ) ) ); + $query = new Posts_Query( $content_type, (int) $request->get_param( 'page' ), @@ -223,6 +237,7 @@ public function get_posts( WP_REST_Request $request ) { $author_id, (array) $request->get_param( 'needs_improvement' ), $scores_enabled, + $include, ); // Posts the current user cannot edit are returned locked and without their SEO data; the per-post diff --git a/src/helpers/current-page-helper.php b/src/helpers/current-page-helper.php index 39e632fee47..f647a666294 100644 --- a/src/helpers/current-page-helper.php +++ b/src/helpers/current-page-helper.php @@ -429,6 +429,18 @@ public function get_current_yoast_seo_page() { return ''; } + /** + * Returns whether the current admin overview (list table) shows the trash. + * + * Mirrors the check WP_Posts_List_Table itself uses to decide it is on the trash view. + * + * @return bool Whether the current admin overview shows the trash. + */ + public function is_trash_overview(): bool { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reason: We are not processing form information. + return isset( $_REQUEST['post_status'] ) && $_REQUEST['post_status'] === 'trash'; + } + /** * Checks if the current global post is the privacy policy page. * diff --git a/tests/Unit/Bulk_Editor/Domain/Posts/Posts_Query_Test.php b/tests/Unit/Bulk_Editor/Domain/Posts/Posts_Query_Test.php index 2a131fbd7ae..7fcda2ca9a2 100644 --- a/tests/Unit/Bulk_Editor/Domain/Posts/Posts_Query_Test.php +++ b/tests/Unit/Bulk_Editor/Domain/Posts/Posts_Query_Test.php @@ -98,4 +98,32 @@ public function test_has_author_filter() { $this->assertTrue( ( new Posts_Query( 'page', 1, 20, '', [], 5 ) )->has_author_filter() ); $this->assertFalse( ( new Posts_Query( 'page', 1, 20, '', [] ) )->has_author_filter() ); } + + /** + * Tests that the included post IDs default to no restriction. + * + * @return void + */ + public function test_get_include_defaults_to_empty() { + $this->assertSame( [], ( new Posts_Query( 'page', 1, 20, '', [] ) )->get_include_ids() ); + } + + /** + * Tests that the included post IDs are carried through when set. + * + * @return void + */ + public function test_get_include() { + $this->assertSame( [ 5, 3 ], ( new Posts_Query( 'page', 1, 20, '', [], null, [], true, [ 5, 3 ] ) )->get_include_ids() ); + } + + /** + * Tests that has_include reflects whether a post ID restriction is set. + * + * @return void + */ + public function test_has_include() { + $this->assertTrue( ( new Posts_Query( 'page', 1, 20, '', [], null, [], true, [ 5, 3 ] ) )->has_include() ); + $this->assertFalse( ( new Posts_Query( 'page', 1, 20, '', [] ) )->has_include() ); + } } 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 2b390ee20aa..fab5bbf8758 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 @@ -253,6 +253,39 @@ public function test_get_posts_with_search() { $this->assertSame( 0, $result['total'] ); } + /** + * Tests that a post ID restriction narrows the query to those posts. + * + * @return void + */ + public function test_get_posts_restricts_to_the_included_post_ids() { + $indexable = new Indexable_Mock(); + $indexable->object_id = 5; + + $query = Mockery::mock( ORM::class ); + $query->allows( 'where' )->andReturnSelf(); + $query->expects( 'where_in' )->with( 'post_status', self::STATUSES )->andReturnSelf(); + $query->expects( 'where_in' )->with( 'object_id', [ 5, 3 ] )->andReturnSelf(); + $query->allows( 'order_by_desc' )->andReturnSelf(); + $query->allows( 'limit' )->andReturnSelf(); + $query->allows( 'offset' )->andReturnSelf(); + // The page is not full, so the total is derived and build_query runs only once. + $query->expects( 'find_many' )->once()->andReturn( [ $indexable ] ); + $query->expects( 'count' )->never(); + + $this->indexable_repository->allows( 'query' )->andReturn( $query ); + + $this->post_editability_resolver->expects( 'resolve' )->with( [ 5 ] )->andReturn( [ 5 => true ] ); + + Functions\expect( 'get_the_title' )->once()->with( 5 )->andReturn( 'Hello world' ); + Functions\expect( 'get_edit_post_link' )->once()->with( 5, 'raw' )->andReturn( 'edit' ); + + $result = $this->instance->get_posts( new Posts_Query( 'page', 1, 20, '', self::STATUSES, null, [], true, [ 5, 3 ] ) )->to_array(); + + $this->assertSame( 5, $result['posts'][0]['id'] ); + $this->assertSame( 1, $result['total'] ); + } + /** * 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/Build_Query_Args_Test.php b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Build_Query_Args_Test.php new file mode 100644 index 00000000000..96aed9e9353 --- /dev/null +++ b/tests/Unit/Bulk_Editor/Infrastructure/Posts/Post_Meta_Posts_Collector/Build_Query_Args_Test.php @@ -0,0 +1,68 @@ + + */ + private const STATUSES = [ 'publish', 'draft', 'pending', 'future' ]; + + /** + * Tests that the included post IDs restrict the query through post__in. + * + * @return void + */ + public function test_build_query_args_restricts_to_the_included_post_ids() { + $args = $this->invoke_build_query_args( new Posts_Query( 'page', 1, 20, '', self::STATUSES, null, [], true, [ 5, 3 ] ) ); + + $this->assertSame( [ 5, 3 ], $args['post__in'] ); + } + + /** + * Tests that post__in is left out when no post IDs are included. + * + * @return void + */ + public function test_build_query_args_without_included_post_ids() { + $args = $this->invoke_build_query_args( new Posts_Query( 'page', 1, 20, '', self::STATUSES ) ); + + $this->assertArrayNotHasKey( 'post__in', $args ); + } + + /** + * Invokes the private build_query_args on a real collector instance. + * + * The construction of the WP_Query consuming these arguments cannot be intercepted (the class name is + * already declared as a plain mock elsewhere in the suite, which rules out an overload mock), so the + * built arguments are asserted directly instead. + * + * @param Posts_Query $query The query describing the page to collect. + * + * @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 ); + + $reflection = new ReflectionMethod( $instance, 'build_query_args' ); + $reflection->setAccessible( true ); + + return $reflection->invoke( $instance, $query ); + } +} diff --git a/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Add_Removable_Query_Args_Test.php b/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Add_Removable_Query_Args_Test.php new file mode 100644 index 00000000000..2dd3830b9c3 --- /dev/null +++ b/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Add_Removable_Query_Args_Test.php @@ -0,0 +1,33 @@ +assertSame( + [ + 'settings-updated', + Bulk_Editor_Integration::POST_IDS_PARAM, + Bulk_Editor_Integration::SELECTED_COUNT_PARAM, + ], + $this->instance->add_removable_query_args( [ 'settings-updated' ] ), + ); + } +} 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 80036187406..a7dfea59a01 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 @@ -58,6 +58,11 @@ public function test_enqueue_assets() { 'contentLocale' => 'en_US', 'keywordAnalysisActive' => true, ], + 'initialSelection' => [ + 'contentType' => '', + 'postIds' => [], + 'selectedCount' => 0, + ], 'myyoastConnection' => null, ]; 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 new file mode 100644 index 00000000000..7389b4ef05d --- /dev/null +++ b/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Get_Initial_Selection_Test.php @@ -0,0 +1,215 @@ +assertSame( + [ + 'contentType' => '', + 'postIds' => [], + 'selectedCount' => 0, + ], + $this->get_initial_selection(), + ); + } + + /** + * Tests that a supported content type is carried over, also without post IDs. + * + * @return void + */ + public function test_carries_a_supported_content_type() { + $_GET[ Bulk_Editor_Integration::CONTENT_TYPE_PARAM ] = 'post'; + + $this->assertSame( + [ + 'contentType' => 'post', + 'postIds' => [], + 'selectedCount' => 0, + ], + $this->get_initial_selection(), + ); + } + + /** + * Tests that an unsupported content type drops the whole selection. + * + * @return void + */ + public function test_ignores_an_unsupported_content_type() { + $_GET[ Bulk_Editor_Integration::CONTENT_TYPE_PARAM ] = 'unsupported'; + $_GET[ Bulk_Editor_Integration::POST_IDS_PARAM ] = '1,2,3'; + + $this->assertSame( + [ + 'contentType' => '', + 'postIds' => [], + 'selectedCount' => 0, + ], + $this->get_initial_selection(), + ); + } + + /** + * Tests that duplicate, non-numeric, zero and negative post IDs are dropped. + * + * @return void + */ + public function test_sanitizes_the_post_ids() { + $_GET[ Bulk_Editor_Integration::CONTENT_TYPE_PARAM ] = 'post'; + $_GET[ Bulk_Editor_Integration::POST_IDS_PARAM ] = '5,3,3,0,-2,junk'; + + $this->assertSame( + [ + 'contentType' => 'post', + 'postIds' => [ 5, 3 ], + 'selectedCount' => 2, + ], + $this->get_initial_selection(), + ); + } + + /** + * Tests that the post IDs are capped at the batch limit while the count keeps the overview's total. + * + * @return void + */ + public function test_caps_the_post_ids_at_the_batch_limit() { + $_GET[ Bulk_Editor_Integration::CONTENT_TYPE_PARAM ] = 'post'; + $_GET[ Bulk_Editor_Integration::POST_IDS_PARAM ] = \implode( ',', \range( 1, 25 ) ); + $_GET[ Bulk_Editor_Integration::SELECTED_COUNT_PARAM ] = '30'; + + $this->assertSame( + [ + 'contentType' => 'post', + 'postIds' => \range( 1, 20 ), + 'selectedCount' => 30, + ], + $this->get_initial_selection(), + ); + } + + /** + * Tests that the selected count can never be lower than the number of carried post IDs. + * + * @return void + */ + public function test_selected_count_never_undercuts_the_post_ids() { + $_GET[ Bulk_Editor_Integration::CONTENT_TYPE_PARAM ] = 'post'; + $_GET[ Bulk_Editor_Integration::POST_IDS_PARAM ] = '1,2,3'; + $_GET[ Bulk_Editor_Integration::SELECTED_COUNT_PARAM ] = '1'; + + $this->assertSame( + [ + 'contentType' => 'post', + 'postIds' => [ 1, 2, 3 ], + 'selectedCount' => 3, + ], + $this->get_initial_selection(), + ); + } + + /** + * Tests that a non-scalar selected count is ignored. + * + * @return void + */ + public function test_ignores_a_non_scalar_selected_count() { + $_GET[ Bulk_Editor_Integration::CONTENT_TYPE_PARAM ] = 'post'; + $_GET[ Bulk_Editor_Integration::POST_IDS_PARAM ] = '1,2,3'; + $_GET[ Bulk_Editor_Integration::SELECTED_COUNT_PARAM ] = [ '25' ]; + + $this->assertSame( + [ + 'contentType' => 'post', + 'postIds' => [ 1, 2, 3 ], + 'selectedCount' => 3, + ], + $this->get_initial_selection(), + ); + } + + /** + * Primes every collaborator the script data needs and returns the initial selection part. + * + * @return array> The initial selection script data. + */ + private function get_initial_selection() { + $this->stubEscapeFunctions(); + Functions\stubs( + [ + 'rest_url' => 'https://example.com/wp-json/', + 'is_rtl' => false, + 'get_locale' => 'en_US', + 'plugins_url' => 'https://example.com/wp-content/plugins/wordpress-seo', + 'admin_url' => static function ( $path ) { + return 'https://example.com/wp-admin/' . $path; + }, + 'wp_unslash' => static function ( $value ) { + return $value; + }, + 'sanitize_text_field' => static function ( $value ) { + return $value; + }, + ], + ); + + $endpoint_list = Mockery::mock( Endpoint_List::class ); + $endpoint_list->allows( 'to_array' )->andReturn( [] ); + + $this->content_types_repository->allows( 'get_content_types' )->andReturn( + [ + [ + 'name' => 'post', + 'label' => 'Posts', + 'singularLabel' => 'Post', + ], + ], + ); + $this->endpoints_repository->allows( 'get_all_endpoints' )->andReturn( $endpoint_list ); + $this->nonce_repository->allows( 'get_rest_nonce' )->andReturn( 'rest-nonce' ); + $this->product_helper->allows( 'is_premium' )->andReturn( false ); + $this->options_helper->allows( 'get' )->andReturn( true ); + $this->short_link_helper->allows( 'get_query_params' )->andReturn( [] ); + $this->myyoast_connection_data_presenter->allows( 'present' )->andReturn( null ); + + return $this->instance->get_script_data()['initialSelection']; + } +} diff --git a/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Register_Hooks_Test.php b/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Register_Hooks_Test.php index 9ef1633a884..e0799e5df8b 100644 --- a/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Register_Hooks_Test.php +++ b/tests/Unit/Bulk_Editor/User_Interface/Bulk_Editor_Integration/Register_Hooks_Test.php @@ -33,6 +33,7 @@ public function test_register_hooks_not_on_bulk_editor_page() { Actions\expectAdded( 'admin_head' )->once()->with( [ $this->instance, 'remove_menu_item' ] ); Actions\expectAdded( 'admin_enqueue_scripts' )->never(); Actions\expectAdded( 'in_admin_header' )->never(); + Filters\expectAdded( 'removable_query_args' )->never(); $this->instance->register_hooks(); } @@ -53,6 +54,7 @@ public function test_register_hooks_on_bulk_editor_page() { Actions\expectAdded( 'admin_head' )->once()->with( [ $this->instance, 'remove_menu_item' ] ); Actions\expectAdded( 'admin_enqueue_scripts' )->once()->with( [ $this->instance, 'enqueue_assets' ] ); Actions\expectAdded( 'in_admin_header' )->once()->with( [ $this->instance, 'remove_notices' ], \PHP_INT_MAX ); + Filters\expectAdded( 'removable_query_args' )->once()->with( [ $this->instance, 'add_removable_query_args' ] ); $this->instance->register_hooks(); } diff --git a/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Abstract_Posts_Overview_Bulk_Actions_Integration_Test.php b/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Abstract_Posts_Overview_Bulk_Actions_Integration_Test.php new file mode 100644 index 00000000000..bd931a2339f --- /dev/null +++ b/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Abstract_Posts_Overview_Bulk_Actions_Integration_Test.php @@ -0,0 +1,94 @@ +content_types_repository = Mockery::mock( Content_Types_Repository::class ); + $this->current_page_helper = Mockery::mock( Current_Page_Helper::class ); + + $this->instance = new Posts_Overview_Bulk_Actions_Integration( + $this->content_types_repository, + $this->current_page_helper, + ); + } + + /** + * Creates a WP_Screen mock for a post overview screen. + * + * @param string $post_type The screen post type. + * + * @return Mockery\MockInterface|WP_Screen The screen mock. + */ + protected function mock_screen( $post_type = 'post' ) { + $screen = Mockery::mock( 'WP_Screen' ); + $screen->post_type = $post_type; + + return $screen; + } + + /** + * Creates the content types repository representation of the given post types. + * + * @param array $post_types The post type names. + * + * @return array> The content types. + */ + protected function content_types_for( array $post_types ) { + $content_types = []; + foreach ( $post_types as $post_type ) { + $content_types[] = [ + 'name' => $post_type, + 'label' => \ucfirst( $post_type ) . 's', + 'singularLabel' => \ucfirst( $post_type ), + ]; + } + + return $content_types; + } +} diff --git a/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Add_Bulk_Action_Test.php b/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Add_Bulk_Action_Test.php new file mode 100644 index 00000000000..2466133ac5e --- /dev/null +++ b/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Add_Bulk_Action_Test.php @@ -0,0 +1,73 @@ +stubTranslationFunctions(); + Functions\when( 'is_rtl' )->justReturn( false ); + $this->current_page_helper->expects( 'is_trash_overview' )->once()->andReturn( false ); + + $actions = [ 'edit' => 'Edit' ]; + + $this->assertSame( + [ + 'edit' => 'Edit', + 'Yoast SEO' => [ + Posts_Overview_Bulk_Actions_Integration::BULK_ACTION => "Bulk edit \u{2197}\u{FE0E}", + ], + ], + $this->instance->add_bulk_action( $actions ), + ); + } + + /** + * Tests that the navigation arrow is mirrored on RTL admins. + * + * @return void + */ + public function test_mirrors_the_arrow_on_rtl() { + $this->stubTranslationFunctions(); + Functions\when( 'is_rtl' )->justReturn( true ); + $this->current_page_helper->expects( 'is_trash_overview' )->once()->andReturn( false ); + + $actions = $this->instance->add_bulk_action( [] ); + + $this->assertSame( + "Bulk edit \u{2196}\u{FE0E}", + $actions['Yoast SEO'][ Posts_Overview_Bulk_Actions_Integration::BULK_ACTION ], + ); + } + + /** + * Tests that the entry is not added on the trash view. + * + * @return void + */ + public function test_does_not_add_the_bulk_action_on_the_trash_view() { + $this->current_page_helper->expects( 'is_trash_overview' )->once()->andReturn( true ); + + $actions = [ 'untrash' => 'Restore' ]; + + $this->assertSame( $actions, $this->instance->add_bulk_action( $actions ) ); + } +} diff --git a/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Constructor_Test.php b/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Constructor_Test.php new file mode 100644 index 00000000000..8f51757e5c6 --- /dev/null +++ b/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Constructor_Test.php @@ -0,0 +1,34 @@ +assertInstanceOf( + Content_Types_Repository::class, + $this->getPropertyValue( $this->instance, 'content_types_repository' ), + ); + $this->assertInstanceOf( + Current_Page_Helper::class, + $this->getPropertyValue( $this->instance, 'current_page_helper' ), + ); + } +} diff --git a/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Get_Conditionals_Test.php b/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Get_Conditionals_Test.php new file mode 100644 index 00000000000..ec7579d1f4d --- /dev/null +++ b/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Get_Conditionals_Test.php @@ -0,0 +1,27 @@ +assertSame( [ Admin_Conditional::class ], Posts_Overview_Bulk_Actions_Integration::get_conditionals() ); + } +} diff --git a/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Handle_Bulk_Action_Test.php b/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Handle_Bulk_Action_Test.php new file mode 100644 index 00000000000..46e0516deda --- /dev/null +++ b/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Handle_Bulk_Action_Test.php @@ -0,0 +1,140 @@ +assertSame( + 'https://example.com/wp-admin/edit.php', + $this->instance->handle_bulk_action( 'https://example.com/wp-admin/edit.php', 'trash', [ 1, 2 ] ), + ); + } + + /** + * Tests that without a current screen the redirect URL is left untouched. + * + * @return void + */ + public function test_leaves_the_redirect_alone_without_a_screen() { + Functions\expect( 'get_current_screen' )->once()->andReturn( null ); + + $this->assertSame( + 'https://example.com/wp-admin/edit.php', + $this->instance->handle_bulk_action( 'https://example.com/wp-admin/edit.php', Posts_Overview_Bulk_Actions_Integration::BULK_ACTION, [ 1 ] ), + ); + } + + /** + * Tests that the action redirects to the bulk editor page with the selection carried over. + * + * @return void + */ + public function test_redirects_to_the_bulk_editor_page_with_the_selection() { + Functions\expect( 'get_current_screen' )->once()->andReturn( $this->mock_screen( 'post' ) ); + + Functions\expect( 'admin_url' ) + ->once() + ->with( 'admin.php' ) + ->andReturn( 'https://example.com/wp-admin/admin.php' ); + Functions\expect( 'add_query_arg' ) + ->once() + ->with( + [ + 'page' => 'wpseo_page_bulk_edit', + 'content_type' => 'post', + 'post_ids' => '5,3', + 'selected_count' => 2, + ], + 'https://example.com/wp-admin/admin.php', + ) + ->andReturn( 'the-bulk-editor-url' ); + + // Duplicate, non-numeric, zero and negative IDs are dropped. + $post_ids = [ '5', 3, 3, 0, -2, 'junk' ]; + + $this->assertSame( + 'the-bulk-editor-url', + $this->instance->handle_bulk_action( 'https://example.com/wp-admin/edit.php', Posts_Overview_Bulk_Actions_Integration::BULK_ACTION, $post_ids ), + ); + } + + /** + * Tests that only the first batch of IDs is carried while the count reflects the whole selection. + * + * @return void + */ + public function test_truncates_the_carried_ids_to_the_batch_limit() { + Functions\expect( 'get_current_screen' )->once()->andReturn( $this->mock_screen( 'page' ) ); + + $post_ids = \range( 1, 25 ); + + Functions\expect( 'admin_url' ) + ->once() + ->with( 'admin.php' ) + ->andReturn( 'https://example.com/wp-admin/admin.php' ); + Functions\expect( 'add_query_arg' ) + ->once() + ->with( + [ + 'page' => 'wpseo_page_bulk_edit', + 'content_type' => 'page', + 'post_ids' => \implode( ',', \range( 1, 20 ) ), + 'selected_count' => 25, + ], + 'https://example.com/wp-admin/admin.php', + ) + ->andReturn( 'the-bulk-editor-url' ); + + $this->assertSame( + 'the-bulk-editor-url', + $this->instance->handle_bulk_action( 'https://example.com/wp-admin/edit.php', Posts_Overview_Bulk_Actions_Integration::BULK_ACTION, $post_ids ), + ); + } + + /** + * Tests that without any usable IDs the redirect only carries the content type. + * + * @return void + */ + public function test_redirects_without_ids_when_none_are_usable() { + Functions\expect( 'get_current_screen' )->once()->andReturn( $this->mock_screen( 'post' ) ); + + Functions\expect( 'admin_url' ) + ->once() + ->with( 'admin.php' ) + ->andReturn( 'https://example.com/wp-admin/admin.php' ); + Functions\expect( 'add_query_arg' ) + ->once() + ->with( + [ + 'page' => 'wpseo_page_bulk_edit', + 'content_type' => 'post', + ], + 'https://example.com/wp-admin/admin.php', + ) + ->andReturn( 'the-bulk-editor-url' ); + + $this->assertSame( + 'the-bulk-editor-url', + $this->instance->handle_bulk_action( 'https://example.com/wp-admin/edit.php', Posts_Overview_Bulk_Actions_Integration::BULK_ACTION, [ 0, 'junk' ] ), + ); + } +} diff --git a/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Register_Bulk_Actions_Test.php b/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Register_Bulk_Actions_Test.php new file mode 100644 index 00000000000..e8de9fd61a1 --- /dev/null +++ b/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Register_Bulk_Actions_Test.php @@ -0,0 +1,81 @@ +once() + ->with( 'wpseo_manage_options' ) + ->andReturn( false ); + + $this->content_types_repository->expects( 'get_content_types' )->never(); + + Filters\expectAdded( 'bulk_actions-edit-post' )->never(); + Filters\expectAdded( 'handle_bulk_actions-edit-post' )->never(); + + $this->instance->register_bulk_actions(); + } + + /** + * Tests that the bulk action filters are registered for every supported post type. + * + * @return void + */ + public function test_registers_the_bulk_action_filters_per_supported_post_type() { + Functions\expect( 'current_user_can' ) + ->once() + ->with( 'wpseo_manage_options' ) + ->andReturn( true ); + + $this->content_types_repository->expects( 'get_content_types' ) + ->once() + ->andReturn( $this->content_types_for( [ 'post', 'page' ] ) ); + + Filters\expectAdded( 'bulk_actions-edit-post' )->once()->with( [ $this->instance, 'add_bulk_action' ] ); + Filters\expectAdded( 'handle_bulk_actions-edit-post' )->once()->with( [ $this->instance, 'handle_bulk_action' ], 10, 3 ); + Filters\expectAdded( 'bulk_actions-edit-page' )->once()->with( [ $this->instance, 'add_bulk_action' ] ); + Filters\expectAdded( 'handle_bulk_actions-edit-page' )->once()->with( [ $this->instance, 'handle_bulk_action' ], 10, 3 ); + + $this->instance->register_bulk_actions(); + } + + /** + * Tests that without supported post types no filters are registered. + * + * @return void + */ + public function test_registers_nothing_without_supported_post_types() { + Functions\expect( 'current_user_can' ) + ->once() + ->with( 'wpseo_manage_options' ) + ->andReturn( true ); + + $this->content_types_repository->expects( 'get_content_types' ) + ->once() + ->andReturn( [] ); + + Filters\expectAdded( 'bulk_actions-edit-post' )->never(); + Filters\expectAdded( 'handle_bulk_actions-edit-post' )->never(); + + $this->instance->register_bulk_actions(); + } +} diff --git a/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Register_Hooks_Test.php b/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Register_Hooks_Test.php new file mode 100644 index 00000000000..0f4820903b4 --- /dev/null +++ b/tests/Unit/Bulk_Editor/User_Interface/Posts_Overview_Bulk_Actions_Integration/Register_Hooks_Test.php @@ -0,0 +1,28 @@ +once()->with( [ $this->instance, 'register_bulk_actions' ] ); + + $this->instance->register_hooks(); + } +} diff --git a/tests/Unit/Bulk_Editor/User_Interface/Posts_Route/Get_Posts_Test.php b/tests/Unit/Bulk_Editor/User_Interface/Posts_Route/Get_Posts_Test.php index a3e0a3c3c5d..ac55936091e 100644 --- a/tests/Unit/Bulk_Editor/User_Interface/Posts_Route/Get_Posts_Test.php +++ b/tests/Unit/Bulk_Editor/User_Interface/Posts_Route/Get_Posts_Test.php @@ -37,6 +37,7 @@ public function test_get_posts_without_author_restriction() { $request->expects( 'get_param' )->with( 'search' )->andReturn( 'seo' ); $request->expects( 'get_param' )->with( 'status' )->andReturn( [ 'draft', 'pending' ] ); $request->expects( 'get_param' )->with( 'needs_improvement' )->andReturn( [ 'seo_title' ] ); + $request->expects( 'get_param' )->with( 'include' )->andReturn( [] ); $this->options_helper->expects( 'get' )->with( 'keyword_analysis_active' )->andReturn( true ); $this->content_types_repository @@ -92,6 +93,7 @@ public function test_get_posts_disables_scores_when_seo_analysis_inactive() { $request->expects( 'get_param' )->with( 'search' )->andReturn( '' ); $request->expects( 'get_param' )->with( 'status' )->andReturn( [ 'publish' ] ); $request->expects( 'get_param' )->with( 'needs_improvement' )->andReturn( [ 'seo_title' ] ); + $request->expects( 'get_param' )->with( 'include' )->andReturn( [] ); $this->options_helper->expects( 'get' )->with( 'keyword_analysis_active' )->andReturn( false ); $this->content_types_repository @@ -142,6 +144,7 @@ public function test_get_posts_restricts_to_own_posts_when_user_cannot_edit_othe $request->expects( 'get_param' )->with( 'search' )->andReturn( '' ); $request->expects( 'get_param' )->with( 'status' )->andReturn( [ 'publish' ] ); $request->expects( 'get_param' )->with( 'needs_improvement' )->andReturn( [] ); + $request->expects( 'get_param' )->with( 'include' )->andReturn( [] ); $this->content_types_repository ->expects( 'get_content_types' ) @@ -193,6 +196,7 @@ public function test_get_posts_falls_back_to_all_statuses_when_none_selected() { $request->expects( 'get_param' )->with( 'search' )->andReturn( '' ); $request->expects( 'get_param' )->with( 'status' )->andReturn( [] ); $request->expects( 'get_param' )->with( 'needs_improvement' )->andReturn( [] ); + $request->expects( 'get_param' )->with( 'include' )->andReturn( [] ); $this->options_helper->expects( 'get' )->with( 'keyword_analysis_active' )->andReturn( true ); $this->content_types_repository @@ -230,6 +234,57 @@ static function ( $query ) { $this->assertInstanceOf( WP_REST_Response::class, $this->instance->get_posts( $request ) ); } + /** + * Tests that the included post IDs are deduplicated and carried into the query. + * + * @return void + */ + public function test_get_posts_carries_the_included_post_ids() { + $request = Mockery::mock( WP_REST_Request::class ); + $request->expects( 'get_param' )->with( 'content_type' )->andReturn( 'page' ); + $request->expects( 'get_param' )->with( 'page' )->andReturn( 1 ); + $request->expects( 'get_param' )->with( 'per_page' )->andReturn( 20 ); + $request->expects( 'get_param' )->with( 'search' )->andReturn( '' ); + $request->expects( 'get_param' )->with( 'status' )->andReturn( [ 'publish' ] ); + $request->expects( 'get_param' )->with( 'needs_improvement' )->andReturn( [] ); + $request->expects( 'get_param' )->with( 'include' )->andReturn( [ 5, '3', 3, 5 ] ); + $this->options_helper->expects( 'get' )->with( 'keyword_analysis_active' )->andReturn( true ); + + $this->content_types_repository + ->expects( 'get_content_types' ) + ->once() + ->andReturn( + [ + [ + 'name' => 'page', + 'label' => 'Pages', + ], + ], + ); + + $this->content_type_access_checker->expects( 'can_edit_others' )->with( 'page' )->andReturnTrue(); + + $posts_page = Mockery::mock( Posts_Page::class ); + $posts_page->expects( 'to_array' )->once()->andReturn( [] ); + + $this->posts_repository + ->expects( 'get_posts' ) + ->once() + ->with( + Mockery::on( + static function ( $query ) { + return $query instanceof Posts_Query + && $query->get_include_ids() === [ 5, 3 ]; + }, + ), + ) + ->andReturn( $posts_page ); + + Mockery::mock( 'overload:' . WP_REST_Response::class ); + + $this->assertInstanceOf( WP_REST_Response::class, $this->instance->get_posts( $request ) ); + } + /** * Tests that an unknown content type returns a WP_Error. * diff --git a/tests/Unit/Bulk_Editor/User_Interface/Posts_Route/Register_Routes_Test.php b/tests/Unit/Bulk_Editor/User_Interface/Posts_Route/Register_Routes_Test.php index 84fa4522e83..77e2033e98e 100644 --- a/tests/Unit/Bulk_Editor/User_Interface/Posts_Route/Register_Routes_Test.php +++ b/tests/Unit/Bulk_Editor/User_Interface/Posts_Route/Register_Routes_Test.php @@ -83,6 +83,17 @@ public function test_register_routes() { ], 'description' => 'The fields to filter posts by; a field matches when it is empty, or (for search fields with SEO analysis enabled) when its score needs improvement.', ], + 'include' => [ + 'required' => false, + 'type' => 'array', + 'default' => [], + 'maxItems' => 100, + 'items' => [ + 'type' => 'integer', + 'minimum' => 1, + ], + 'description' => 'Limits the posts to these post IDs, e.g. a selection carried over from the posts overview.', + ], ], 'callback' => [ $this->instance, 'get_posts' ], 'permission_callback' => [ $this->instance, 'check_permissions' ], diff --git a/tests/Unit/Helpers/Current_Page_Helper_Test.php b/tests/Unit/Helpers/Current_Page_Helper_Test.php index 0b4315c119f..578fcdb99f5 100644 --- a/tests/Unit/Helpers/Current_Page_Helper_Test.php +++ b/tests/Unit/Helpers/Current_Page_Helper_Test.php @@ -894,4 +894,45 @@ public function test_get_current_yoast_seo_page_page_is_int() { $this->assertEquals( null, $this->instance->get_current_yoast_seo_page() ); } + + /** + * Test is_trash_overview function when the overview shows the trash. + * + * @covers ::is_trash_overview + * + * @return void + */ + public function test_is_trash_overview() { + $_REQUEST['post_status'] = 'trash'; + + $this->assertTrue( $this->instance->is_trash_overview() ); + + unset( $_REQUEST['post_status'] ); + } + + /** + * Test is_trash_overview function when the overview shows another status. + * + * @covers ::is_trash_overview + * + * @return void + */ + public function test_is_trash_overview_other_status() { + $_REQUEST['post_status'] = 'draft'; + + $this->assertFalse( $this->instance->is_trash_overview() ); + + unset( $_REQUEST['post_status'] ); + } + + /** + * Test is_trash_overview function when no status is requested. + * + * @covers ::is_trash_overview + * + * @return void + */ + public function test_is_trash_overview_without_status() { + $this->assertFalse( $this->instance->is_trash_overview() ); + } }