• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Yoast / wordpress-seo / d7104d620645ffc21d27718d0ffcc7596bf05496

29 Jul 2026 10:32AM UTC coverage: 55.747% (+0.1%) from 55.63%
d7104d620645ffc21d27718d0ffcc7596bf05496

Pull #23488

github

web-flow
Merge 6f7ae8150 into 93fa1d79c
Pull Request #23488: 1311 add edit with yoast bulk editor entry to wp admin content type bulk actions dropdown

10665 of 18952 branches covered (56.27%)

Branch coverage included in aggregate %.

146 of 146 new or added lines in 15 files covered. (100.0%)

3 existing lines in 1 file now uncovered.

40605 of 73017 relevant lines covered (55.61%)

41218.17 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

84.27
/packages/js/src/bulk-editor/components/bulk-editor-content.js
1
import { Slot } from "@wordpress/components";
2
import { useDispatch, useSelect } from "@wordpress/data";
3
import { useCallback, useEffect, useMemo } from "@wordpress/element";
4
import { __ } from "@wordpress/i18n";
5
import { BULK_UPDATE_BATCH_SIZE, PENDING_CHANGES_MODAL_SLOT, STORE_NAME } from "../constants";
6
import { getFieldSets } from "../field-sets";
7
import { useInlineEdit } from "../hooks/use-inline-edit";
8
import { usePosts } from "../services/use-posts";
9
import { BulkActions, SelectionToolbar } from "./bulk-action-bar";
10
import { BulkEditorFilters } from "./bulk-editor-filters";
11
import { BulkEditorFooter } from "./bulk-editor-footer";
12
import { BulkEditorTable } from "./table/bulk-editor-table";
13
import { BulkEditorTabPanel, BulkEditorTabs } from "./bulk-editor-tabs";
14
import { UnsavedChangesModal } from "./unsaved-changes-modal";
15
import { SearchBox } from "./search-box";
16

17
/**
18
 * Generates the selection toolbar's view. While loading, the previous content type's items and selection still
19
 * linger behind the skeleton rows, so a neutral (empty) selection is presented instead.
20
 *
21
 * @param {boolean}  isLoading   Whether the rows are still loading.
22
 * @param {number[]} selectedIds The currently selected item ids.
23
 * @param {Object[]} items       The loaded items (per page).
24
 * @param {number}   total       The total number of items across all pages.
25
 *
26
 * @returns {{isAllSelected: boolean, isIndeterminate: boolean, selectedCount: number, totalCount: number, hasSelection: boolean}} The selection view.
27
 */
28
export const getSelectionView = ( isLoading, selectedIds, items, total ) => {
24✔
29
        if ( isLoading ) {
320✔
30
                return { isAllSelected: false, isIndeterminate: false, selectedCount: 0, totalCount: 0, hasSelection: false };
190✔
31
        }
32
        // Only posts the user can edit are selectable, so "all selected" is measured against the editable rows.
33
        // Measured by membership, not count: a selection carried over from the WP admin overview can contain
34
        // rows that are not on the current page.
35
        const selectableIds = items.filter( ( item ) => item.editable ).map( ( item ) => item.id );
274✔
36
        const selectedCount = selectedIds.length;
130✔
37
        const isAllSelected = selectableIds.length > 0 && selectableIds.every( ( id ) => selectedIds.includes( id ) );
152✔
38
        return {
130✔
39
                isAllSelected,
40
                isIndeterminate: selectedCount > 0 && ! isAllSelected,
71✔
41
                selectedCount,
42
                totalCount: total,
43
                hasSelection: selectedCount > 0,
44
        };
45
};
46

47
/**
48
 * Decides whether the bulk-actions band row is expanded.
49
 *
50
 * A selection only warrants the band while AI is enabled (the AI affordances are its only selection-driven
51
 * occupant); with AI off the band collapses. Unsaved manual edits are a separate, non-AI occupant, so they
52
 * keep it open regardless of the AI toggle. External pending changes (Premium's AI suggestions) also keep
53
 * it open: a filter, search, or page change clears the selection but must leave the pending suggestions
54
 * actionable. The overview-selection truncation and exclusion notices live in the band's notices region,
55
 * so either opens the band too.
56
 *
57
 * @param {Object}  view                           The view state.
58
 * @param {boolean} view.hasSelection              Whether any rows are selected.
59
 * @param {boolean} view.isAiEnabled               Whether the AI feature is enabled.
60
 * @param {boolean} view.hasUnsavedEdits           Whether a row has unsaved manual edits.
61
 * @param {boolean} view.hasExternalPendingChanges Whether an external plugin reports pending changes.
62
 * @param {boolean} view.hasOverviewNotice         Whether an overview-selection notice (truncation or exclusion) must show.
63
 *
64
 * @returns {boolean} Whether the band is expanded.
65
 */
66
export const shouldShowBulkActions = ( { hasSelection, isAiEnabled, hasUnsavedEdits, hasExternalPendingChanges, hasOverviewNotice } ) =>
24✔
67
        ( hasSelection && isAiEnabled ) || hasUnsavedEdits || hasExternalPendingChanges || hasOverviewNotice;
316✔
68

69
/**
70
 * Decides whether an overview-selection notice (truncation or exclusion) must show.
71
 *
72
 * @param {Object}  view                        The view state.
73
 * @param {number}  view.preselectedTotal       How many items were selected on the WP admin overview.
74
 * @param {boolean} view.hasExcludedPreselected Whether pruning dropped carried-over ids.
75
 *
76
 * @returns {boolean} Whether an overview-selection notice must show.
77
 */
78
export const getHasOverviewNotice = ( { preselectedTotal, hasExcludedPreselected } ) =>
24✔
79
        preselectedTotal > BULK_UPDATE_BATCH_SIZE || hasExcludedPreselected;
312✔
80

81
/**
82
 * The bulk editor content.
83
 *
84
 * @param {Object}                             props                    The props.
85
 * @param {import("../services").DataProvider} props.dataProvider       The data provider (config + endpoints).
86
 * @param {Object}                             props.remoteDataProvider The remote data provider (HTTP), used to fetch and save.
87
 * @param {string}                             props.contentType        The active content type to fetch posts for.
88
 * @param {string}                             props.contentTypeLabel   The active content type label, used in the search placeholder.
89
 * @param {string}                             props.contentTypeSingularLabel The active content type singular label, passed to the bulk actions.
90
 *
91
 * @returns {JSX.Element} The content.
92
 */
93
export const BulkEditorContent = ( { dataProvider, remoteDataProvider, contentType, contentTypeLabel, contentTypeSingularLabel } ) => {
24✔
94
        const fieldSets = useMemo( () => getFieldSets(), [] );
304✔
95
        const tabs = useMemo(
304✔
96
                () => Object.values( fieldSets ).map( ( { id, label } ) => ( { id, label } ) ),
112✔
97
                [ fieldSets ]
98
        );
99
        const {
100
                activeFieldSet,
101
                selectedIds,
102
                preselectedTotal,
103
                hasExcludedPreselected,
104
                isPremium,
105
                isAiEnabled,
106
                hasExternalPendingChanges,
107
                hasExternalGeneration,
108
                pendingSwitch,
109
        } = useSelect( ( select ) => {
304✔
110
                const store = select( STORE_NAME );
214✔
111
                return {
214✔
112
                        activeFieldSet: store.selectActiveFieldSet(),
113
                        selectedIds: store.selectSelectedIds(),
114
                        // The size of a selection carried over from the WP admin overview; drives the truncation notice.
115
                        preselectedTotal: store.selectPreselectedTotal(),
116
                        // Whether pruning dropped carried-over ids the bulk editor cannot show or edit; drives the exclusion notice.
117
                        hasExcludedPreselected: store.selectHasExcludedPreselected(),
118
                        isPremium: store.selectPreference( "isPremium", false ),
119
                        isAiEnabled: store.selectPreference( "isAiEnabled", false ),
120
                        // An external plugin (e.g. Premium's AI suggestions) reports pending changes so the switch can be guarded.
121
                        hasExternalPendingChanges: store.selectHasExternalPendingChanges(),
122
                        // It also reports an in-flight generation request so row editing can be locked while it runs.
123
                        hasExternalGeneration: store.selectHasExternalGeneration(),
124
                        pendingSwitch: store.selectPendingSwitch(),
125
                };
126
        }, [] );
127
        const {
128
                requestSwitch, commitSwitch, clearPendingSwitch, toggleRow, selectAll, deselectAll, dismissPreselectionNotice, dismissExclusionNotice,
129
        } = useDispatch( STORE_NAME );
304✔
130

131
        const { data: items = [], total = 0, totalPages = 0, isPending, updateItem } = usePosts( { dataProvider, remoteDataProvider, contentType } );
304!
132
        const { editing, stopEditing } = useInlineEdit( { dataProvider, remoteDataProvider, fieldSets, activeFieldSet, items, updateItem } );
304✔
133

134
        const editCount = Object.keys( editing.editingRows ).length;
304✔
135
        const hasUnsavedEdits = editCount > 0;
304✔
136

137
        // A tab click requests a field-set switch; requestSwitch guards it (defers to the modal), skips a no-op switch,
138
        // or commits straight away. Kept free of the active field set so the handler stays referentially stable.
139
        const onChangeTab = useCallback( ( id ) => requestSwitch( { kind: "fieldSet", target: id } ), [ requestSwitch ] );
304✔
140

141
        const onSaveAndSwitch = useCallback( async() => {
304✔
142
                // Close the modal only when the save actually failed, so its notice is revealed; a clean save lets the
143
                // self-heal effect complete the switch, and an in-flight save (null) is left alone.
144
                const saved = await editing.onApplyAll();
2✔
145
                if ( saved === false ) {
2!
UNCOV
146
                        clearPendingSwitch();
×
147
                }
148
        }, [ editing, clearPendingSwitch ] );
149

150
        const onDiscardAndSwitch = useCallback( () => {
304✔
151
                // Clearing the edits flips hasUnsavedEdits to false; the self-heal effect or the slot modal then completes
152
                // the switch, so a still-pending external guard is honoured rather than overridden.
153
                stopEditing();
4✔
154
        }, [ stopEditing ] );
155

156
        const onCancelSwitch = useCallback( () => clearPendingSwitch(), [ clearPendingSwitch ] );
304✔
157

158
        // Commits the deferred switch for an external guard (Premium fills the slot below and calls this once it has
159
        // handled its own pending changes). Free's own manual edits use onSaveAndSwitch/onDiscardAndSwitch instead.
160
        const onCommitSwitch = useCallback( () => {
304✔
161
                if ( pendingSwitch ) {
10!
162
                        commitSwitch( pendingSwitch );
10✔
163
                }
164
        }, [ pendingSwitch, commitSwitch ] );
165

166
        // Self-heal a stranded switch: if a deferral is outstanding but nothing guards it any more (manual edits saved
167
        // and the external plugin cleared its pending changes), complete the switch so the user can never get stuck
168
        // with no modal to resolve.
169
        useEffect( () => {
304✔
170
                if ( pendingSwitch !== null && ! hasUnsavedEdits && ! hasExternalPendingChanges ) {
142✔
171
                        onCommitSwitch();
8✔
172
                }
173
        }, [ pendingSwitch, hasUnsavedEdits, hasExternalPendingChanges, onCommitSwitch ] );
174

175
        const { isAllSelected, isIndeterminate, selectedCount, totalCount, hasSelection } = getSelectionView( isPending, selectedIds, items, total );
304✔
176
        // The truncation and exclusion notices for a selection carried over from the WP admin overview, shown in the
177
        // band's notices region; either one keeps the band expanded.
178
        const hasOverviewNotice = getHasOverviewNotice( { preselectedTotal, hasExcludedPreselected } );
304✔
179
        const showBulkActions = shouldShowBulkActions( { hasSelection, isAiEnabled, hasUnsavedEdits, hasExternalPendingChanges, hasOverviewNotice } );
304✔
180
        const onSelectAll = useCallback( () => {
304✔
UNCOV
181
                if ( ! isPending ) {
×
182
                        // Only posts the user can edit are selectable for bulk editing.
UNCOV
183
                        selectAll( items.filter( ( item ) => item.editable ).map( ( item ) => item.id ) );
×
184
                }
185
        }, [ isPending, selectAll, items ] );
186
        // Clicking the master checkbox clears the selection whenever anything is selected (all or a partial).
187
        const onToggleAll = useCallback( () => ( hasSelection ? deselectAll() : onSelectAll() ), [ hasSelection, deselectAll, onSelectAll ] );
304!
188

189
        const selection = useMemo( () => ( {
304✔
190
                selectedIds,
191
                onToggleRow: toggleRow,
192
        } ), [ selectedIds, toggleRow ] );
193

194
        return (
304✔
195
                <div className="yst-p-8 yst-space-y-6">
196
                        <div className="yst-flex yst-flex-col yst-gap-4 sm:yst-flex-row sm:yst-items-start sm:yst-justify-between">
197
                                <BulkEditorTabs
198
                                        tabs={ tabs }
199
                                        activeTab={ activeFieldSet }
200
                                        disabled={ hasExternalGeneration }
201
                                        onChange={ onChangeTab }
202
                                        label={ __( "Bulk editor views", "wordpress-seo" ) }
203
                                />
204
                                <SearchBox contentTypeLabel={ contentTypeLabel } />
205
                        </div>
206
                        { tabs.map( ( tab ) => (
207
                                <BulkEditorTabPanel key={ tab.id } tabId={ tab.id } isActive={ tab.id === activeFieldSet }>
608✔
208
                                        <BulkEditorTable
209
                                                items={ items }
210
                                                fieldSet={ fieldSets[ tab.id ] }
211
                                                selection={ selection }
212
                                                editing={ editing }
213
                                                selectionToolbar={
214
                                                        <SelectionToolbar
215
                                                                idSuffix={ `-${ tab.id }` }
216
                                                                isAllSelected={ isAllSelected }
217
                                                                isIndeterminate={ isIndeterminate }
218
                                                                onToggleAll={ onToggleAll }
219
                                                                onSelectAll={ onSelectAll }
220
                                                                onDeselectAll={ deselectAll }
221
                                                                selectedCount={ selectedCount }
222
                                                                totalCount={ totalCount }
223
                                                                contentTypeLabel={ contentTypeLabel }
224
                                                        />
225
                                                }
226
                                                bulkActions={
227
                                                        <BulkActions
228
                                                                isPremium={ isPremium }
229
                                                                isAiEnabled={ isAiEnabled }
230
                                                                isActive={ tab.id === activeFieldSet }
231
                                                                selectedIds={ selectedIds }
232
                                                                activeFieldSet={ activeFieldSet }
233
                                                                contentType={ contentType }
234
                                                                contentTypeLabel={ contentTypeLabel }
235
                                                                contentTypeSingularLabel={ contentTypeSingularLabel }
236
                                                                hasUnsavedEdits={ hasUnsavedEdits }
237
                                                                editCount={ editCount }
238
                                                                onApplyAll={ editing.onApplyAll }
239
                                                                onDiscardAll={ editing.onDiscardAll }
240
                                                                isApplyingAll={ editing.isApplyingAll }
241
                                                                hasSaveError={ editing.hasSaveError }
242
                                                                onDismissSaveError={ editing.dismissSaveError }
243
                                                                preselectedTotal={ preselectedTotal }
244
                                                                onDismissPreselection={ dismissPreselectionNotice }
245
                                                                hasExcludedPreselected={ hasExcludedPreselected }
246
                                                                onDismissExclusion={ dismissExclusionNotice }
247
                                                        />
248
                                                }
249
                                                showBulkActions={ showBulkActions }
250
                                                filters={ <BulkEditorFilters /> }
251
                                                isLoading={ isPending }
252
                                                hasExternalPendingChanges={ hasExternalPendingChanges }
253
                                                hasExternalGeneration={ hasExternalGeneration }
254
                                                footer={ total > 0
304✔
255
                                                        ? <BulkEditorFooter total={ total } totalPages={ totalPages } isPending={ isPending } />
256
                                                        : null }
257
                                        />
258
                                </BulkEditorTabPanel>
259
                        ) ) }
260
                        <UnsavedChangesModal
261
                                isOpen={ hasUnsavedEdits && pendingSwitch !== null }
185✔
262
                                isSaving={ editing.isApplyingAll }
263
                                onSave={ onSaveAndSwitch }
264
                                onDiscard={ onDiscardAndSwitch }
265
                                onClose={ onCancelSwitch }
266
                        />
267
                        <Slot
268
                                name={ PENDING_CHANGES_MODAL_SLOT }
269
                                fillProps={ {
270
                                        isOpen: pendingSwitch !== null && ! hasUnsavedEdits,
168✔
271
                                        onCommit: onCommitSwitch,
272
                                        onCancel: onCancelSwitch,
273
                                } }
274
                        />
275
                </div>
276
        );
277
};
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc