Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 41 additions & 4 deletions packages/js/src/bulk-editor/components/bulk-editor-content.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useDispatch, useSelect } from "@wordpress/data";
import { useCallback, useEffect, useMemo } from "@wordpress/element";
import { useCallback, useEffect, useMemo, useState } from "@wordpress/element";
import { __ } from "@wordpress/i18n";
import { STORE_NAME } from "../constants";
import { getFieldSets } from "../field-sets";
Expand All @@ -10,6 +10,7 @@ import { BulkEditorFilters } from "./bulk-editor-filters";
import { BulkEditorFooter } from "./bulk-editor-footer";
import { BulkEditorTable } from "./table/bulk-editor-table";
import { BulkEditorTabPanel, BulkEditorTabs } from "./bulk-editor-tabs";
import { UnsavedChangesModal } from "./unsaved-changes-modal";
import { SearchBox } from "./search-box";

/**
Expand Down Expand Up @@ -65,11 +66,41 @@ export const BulkEditorContent = ( { dataProvider, remoteDataProvider, contentTy
const { data: items = [], total = 0, totalPages = 0, isPending, updateItem } = usePosts( { dataProvider, remoteDataProvider, contentType } );
const { editing, stopEditing } = useInlineEdit( { dataProvider, remoteDataProvider, fieldSets, activeFieldSet, items, updateItem } );

// Switching tab discards in-progress edits; switching content type also clears the selection.
// The tab the user wants to switch to while rows still have unsaved edits; drives the confirmation modal.
const [ pendingTab, setPendingTab ] = useState( null );
const hasUnsavedEdits = Object.keys( editing.editingRows ).length > 0;


const onChangeTab = useCallback( ( id ) => {
stopEditing();
if ( id === activeFieldSet ) {
return;
}
// Guard the switch when edits are in progress; otherwise switch straight away.
if ( Object.keys( editing.editingRows ).length > 0 ) {
Comment thread
vraja-pro marked this conversation as resolved.
setPendingTab( id );
return;
}
setActiveFieldSet( id );
}, [ stopEditing, setActiveFieldSet ] );
}, [ activeFieldSet, editing.editingRows, setActiveFieldSet ] );

const onSaveAndSwitch = useCallback( () => {
// Fire the save for every open field; each reads its draft synchronously, so clearing the edit state
// right after still posts the captured values while leaving the new tab clean.
Object.entries( editing.editingRows ).forEach( ( [ id, row ] ) =>
row.openFields.forEach( ( key ) => editing.onApplyField( { id: Number( id ), key } ) )
);
stopEditing();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged in the PR notes, flagging here for visibility: stopEditing() clears the local edit state and the tab switch happens regardless of whether the onApplyField promises resolve or reject. A save failure on this path is silently dropped — the user won't know a field didn't save. Acceptable for a "leaving anyway" flow, but worth a follow-up to at least surface a toast/notice if any promise rejects.

setActiveFieldSet( pendingTab );
setPendingTab( null );
}, [ editing, stopEditing, pendingTab, setActiveFieldSet ] );

const onDiscardAndSwitch = useCallback( () => {
stopEditing();
setActiveFieldSet( pendingTab );
setPendingTab( null );
}, [ stopEditing, pendingTab, setActiveFieldSet ] );

const onCancelSwitch = useCallback( () => setPendingTab( null ), [] );

useEffect( () => {
deselectAll();
Expand Down Expand Up @@ -125,6 +156,12 @@ export const BulkEditorContent = ( { dataProvider, remoteDataProvider, contentTy
/>
</BulkEditorTabPanel>
) ) }
<UnsavedChangesModal
isOpen={ hasUnsavedEdits && pendingTab !== null }
onSave={ onSaveAndSwitch }
Comment thread
vraja-pro marked this conversation as resolved.
onDiscard={ onDiscardAndSwitch }
onClose={ onCancelSwitch }
/>
<BulkEditorFooter total={ total } totalPages={ totalPages } isPending={ isPending } />
</div>
);
Expand Down
2 changes: 1 addition & 1 deletion packages/js/src/bulk-editor/components/bulk-editor-tabs.js
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ export const BulkEditorTabs = ( { tabs, activeTab, onChange, label } ) => {
}, [ tabs, activeTab, onChange ] );

return (
<div ref={ listRef } role="tablist" aria-label={ label } className="yst-flex yst-gap-2">
<div ref={ listRef } role="tablist" aria-label={ label } className="yst-flex yst-gap-4">
{ tabs.map( ( tab ) => (
<Tab
key={ tab.id }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,14 @@ const getTableClassName = ( isLoading ) =>
* @property {Function} [onToggleAll] Called when the header "select all" checkbox is toggled.
*/
/**
* The bulk editor inline-edit props. Editing is per field and several rows can edit at once: a row's Edit opens
* its field-set fields, and each open field is applied (saved on its own) or discarded independently.
* The bulk editor inline-edit props. Several rows can edit at once: a row's Edit opens its field-set fields,
* then the row's Save saves every open field and Cancel discards all of them at once.
*
* @typedef {Object} BulkEditorEditing
* @property {Object} [editingRows] Edit state keyed by item id: `{ [id]: { openFields, draft, savingFields } }`.
* @property {Function} [onStartEdit] Called with an item id to enter edit mode.
* @property {Function} [onChangeField] Called with `{ id, key, value }` when a field changes.
* @property {Function} [onApplyField] Called with `{ id, key }` to save that field.
* @property {Function} [onDiscardField] Called with `{ id, key }` to discard that field's changes.
* @property {Function} [onCancelEdit] Called with an item id to cancel all of its open fields at once.
*/

Expand Down Expand Up @@ -66,7 +65,6 @@ export const BulkEditorTable = ( {
onStartEdit: noop,
onChangeField: noop,
onApplyField: noop,
onDiscardField: noop,
onCancelEdit: noop,
...editing,
};
Expand Down
90 changes: 24 additions & 66 deletions packages/js/src/bulk-editor/components/table/table-cells.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import CheckIcon from "@heroicons/react/outline/CheckIcon";
import XIcon from "@heroicons/react/outline/XIcon";
import { useCallback, useEffect, useState } from "@wordpress/element";
import { __, sprintf } from "@wordpress/i18n";
import { Button, Table, Textarea } from "@yoast/ui-library";
import AnimateHeight from "react-animate-height";
import { Table, Textarea } from "@yoast/ui-library";
import { getStatusLabel } from "./table-helpers";
import AnimateHeight from "react-animate-height";

/**
* The title cell (the row header).
Expand All @@ -30,80 +28,40 @@ export const TitleCell = ( { item } ) => {
};

/**
* An open field cell: the input plus its Apply and Discard actions. Each field is saved or discarded on its own.
* An open field cell: an editable textarea. The row's Save and Cancel actions save or
* discard all of the row's open fields at once.
*
* @param {Object} props The props.
* @param {FieldSetField} props.field The field this cell edits.
* @param {number} props.itemId The item id, to keep the input id unique across rows.
* @param {string} props.itemTitle The item title, for the accessible names.
* @param {string} props.value The current draft value.
* @param {boolean} props.isSaving Whether this field is being saved (disables the controls).
* @param {boolean} props.isOpen Whether the field is open; flipping it false collapses the cell.
* @param {Function} props.onChange Called when the value changes.
* @param {Function} props.onApply Called with the field key to save it.
* @param {Function} props.onRequestClose Called with the field key to start the discard (collapse) of this field.
* @param {Function} props.onClosed Called with the field key once the collapse animation has finished.
* @param {Object} props The props.
* @param {FieldSetField} props.field The field this cell edits.
* @param {number} props.itemId The item id, to keep the input id unique across rows.
* @param {string} props.itemTitle The item title, for the accessible name.
* @param {string} props.value The current draft value.
* @param {boolean} props.isSaving Whether the row is being saved (disables the input).
* @param {Function} props.onChange Called with { key, value } when the value changes.
* @param {boolean} props.isOpen Whether the field is open for editing.
*
* @returns {JSX.Element} The cell.
*/
export const EditableFieldCell = ( { field, itemId, itemTitle, value, isSaving, isOpen, onChange, onApply, onRequestClose, onClosed } ) => {
export const EditableFieldCell = ( { field, itemId, itemTitle, value, isSaving, onChange, isOpen } ) => {
const handleChange = useCallback( ( event ) => onChange( { key: field.key, value: event.target.value } ), [ onChange, field.key ] );
const handleApply = useCallback( () => onApply( field.key ), [ onApply, field.key ] );
const handleRequestClose = useCallback( () => onRequestClose( field.key ), [ onRequestClose, field.key ] );

// Row expand/collapse animation helper.
const [ height, setHeight ] = useState( 0 );
useEffect( () => setHeight( isOpen ? "auto" : 0 ), [ isOpen ] );

const handleAnimationEnd = useCallback( () => {
if ( ! isOpen ) {
onClosed( field.key );
}
}, [ isOpen, onClosed, field.key ] );

return (
<Table.Cell>
<AnimateHeight easing="ease-in-out" duration={ 300 } height={ height } animateOpacity={ true } onAnimationEnd={ handleAnimationEnd }>
<div className="yst-flex yst-flex-col yst-gap-2">
<div className="yst-bg-ai-300 yst-rounded-md yst-p-px yst-shadow-sm">
<Textarea
id={ `bulk-editor-edit-${ itemId }-${ field.key }` }
rows={ 2 }
value={ value }
onChange={ handleChange }
disabled={ isSaving }
className="yst-block yst-resize-none yst-bg-primary-50 yst-ring-0 yst-shadow-none"
/* translators: %1$s expands to the field label, %2$s to the content item title. */
aria-label={ sprintf( __( "%1$s for %2$s", "wordpress-seo" ), field.label, itemTitle ) }
/>
</div>
<div className="yst-flex yst-gap-2">
<Button
variant="secondary"
size="small"
className="yst-gap-1.5"
onClick={ handleApply }
disabled={ isSaving }
/* translators: %1$s expands to the field label, %2$s to the content item title. */
aria-label={ sprintf( __( "Apply %1$s for %2$s", "wordpress-seo" ), field.label, itemTitle ) }
>
<CheckIcon className="yst-h-4 yst-w-4 yst-text-green-500" aria-hidden="true" />
{ __( "Apply", "wordpress-seo" ) }
</Button>
<Button
variant="secondary"
size="small"
className="yst-gap-1.5"
onClick={ handleRequestClose }
disabled={ isSaving }
/* translators: %1$s expands to the field label, %2$s to the content item title. */
aria-label={ sprintf( __( "Discard %1$s for %2$s", "wordpress-seo" ), field.label, itemTitle ) }
>
<XIcon className="yst-h-4 yst-w-4 yst-text-red-500" aria-hidden="true" />
{ __( "Discard", "wordpress-seo" ) }
</Button>
</div>
</div>
<AnimateHeight easing="ease-in-out" duration={ 300 } height={ height } animateOpacity={ true }>
<Textarea
id={ `bulk-editor-edit-${ itemId }-${ field.key }` }
rows={ 2 }
value={ value }
onChange={ handleChange }
disabled={ isSaving }
className="yst-resize-none"
/* translators: %1$s expands to the field label, %2$s to the content item title. */
aria-label={ sprintf( __( "%1$s for %2$s", "wordpress-seo" ), field.label, itemTitle ) }
/>
</AnimateHeight>
</Table.Cell>
);
Expand Down
99 changes: 51 additions & 48 deletions packages/js/src/bulk-editor/components/table/table-row.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { useCallback, useEffect, useRef, useState } from "@wordpress/element";
import { useCallback } from "@wordpress/element";
import { __, sprintf } from "@wordpress/i18n";
import { Button, Checkbox, Table } from "@yoast/ui-library";
import { EditableFieldCell, TitleCell } from "./table-cells";
import { getRowEditState } from "./table-helpers";

/**
* A content row. Each field-set cell renders as plain text, or — when the row is in edit mode and the field is
* open — as an editable cell with its own Apply/Discard buttons.
* open — as an editable cell. The Actions column then shows the row's Save and Cancel buttons.
*
* @param {Object} props The props.
* @param {BulkEditorItem} props.item The item data.
Expand All @@ -20,43 +20,23 @@ import { getRowEditState } from "./table-helpers";
*/
export const BulkEditorRow = ( { item, fields, isSelected, onToggleRow, edit, editing } ) => {
const { isEditing, openFields, draft, savingFields } = getRowEditState( edit );
const { onStartEdit, onChangeField, onApplyField, onDiscardField } = editing;

const [ closing, setClosing ] = useState( () => new Set() );
const { onStartEdit, onChangeField, onApplyField, onCancelEdit } = editing;
const isSaving = Object.keys( savingFields ).length > 0;

const handleToggle = useCallback( () => onToggleRow( item.id ), [ onToggleRow, item.id ] );
const handleEdit = useCallback( () => onStartEdit( item.id ), [ onStartEdit, item.id ] );
const handleCancel = useCallback( () => onCancelEdit( item.id ), [ onCancelEdit, item.id ] );
const handleChangeField = useCallback( ( { key, value } ) => onChangeField( { id: item.id, key, value } ), [ onChangeField, item.id ] );
const handleApplyField = useCallback( ( key ) => onApplyField( { id: item.id, key } ), [ onApplyField, item.id ] );

// Discard one field, or Cancel the whole row.
const requestCloseField = useCallback( ( key ) => setClosing( ( previous ) => new Set( previous ).add( key ) ), [] );
const handleCancel = useCallback( () => setClosing( new Set( openFields ) ), [ openFields ] );

// Discard the field once it has finished collapsing; the row leaves edit mode when none remain.
const commitCloseField = useCallback( ( key ) => {
setClosing( ( previous ) => {
const next = new Set( previous );
next.delete( key );
return next;
} );
onDiscardField( { id: item.id, key } );
}, [ onDiscardField, item.id ] );

// Return focus to the row's Edit/Cancel button so keyboard users keep their place.
const toggleRef = useRef( null );
const previousOpenCount = useRef( openFields.length );
useEffect( () => {
const fieldClosed = openFields.length < previousOpenCount.current;
previousOpenCount.current = openFields.length;
if ( fieldClosed && document.activeElement === document.body ) {
toggleRef.current?.focus( { preventScroll: true } );
}
}, [ openFields.length ] );
const handleSave = useCallback(
() => openFields.forEach( ( key ) => onApplyField( { id: item.id, key } ) ),
[ openFields, onApplyField, item.id ]
);

/* translators: %s expands to the content item title. */
const editLabel = sprintf( __( "Edit %s", "wordpress-seo" ), item.title );
/* translators: %s expands to the content item title. */
const saveLabel = sprintf( __( "Save %s", "wordpress-seo" ), item.title );
/* translators: %s expands to the content item title. */
const cancelLabel = sprintf( __( "Cancel editing %s", "wordpress-seo" ), item.title );

return (
Expand All @@ -82,29 +62,52 @@ export const BulkEditorRow = ( { item, fields, isSelected, onToggleRow, edit, ed
itemId={ item.id }
itemTitle={ item.title }
value={ draft[ field.key ] ?? "" }
isSaving={ Boolean( savingFields[ field.key ] ) }
isOpen={ ! closing.has( field.key ) }
isSaving={ isSaving }
onChange={ handleChangeField }
onApply={ handleApplyField }
onRequestClose={ requestCloseField }
onClosed={ commitCloseField }
isOpen={ isEditing }
/>
)
: <Table.Cell key={ field.key }>{ item[ field.key ] }</Table.Cell>
) ) }
<Table.Cell>
<span className="yst-flex yst-justify-end">
<Button
ref={ toggleRef }
variant="tertiary"
size="small"
className="yst--me-2.5"
onClick={ isEditing ? handleCancel : handleEdit }
aria-label={ isEditing ? cancelLabel : editLabel }
>
{ isEditing ? __( "Cancel", "wordpress-seo" ) : __( "Edit", "wordpress-seo" ) }
</Button>
</span>
{ isEditing
? (
<span className="yst-flex yst-flex-col yst-items-end yst-gap-1.5 yst--me-2.5">
<Button
variant="tertiary"
size="small"
onClick={ handleSave }
disabled={ isSaving }
aria-label={ saveLabel }
>
{ __( "Save", "wordpress-seo" ) }
</Button>
<span aria-hidden="true" className="yst-w-full yst-border-t yst-border-slate-200" />
<Button
variant="tertiary"
size="small"
onClick={ handleCancel }
disabled={ isSaving }
aria-label={ cancelLabel }
>
{ __( "Cancel", "wordpress-seo" ) }
</Button>
</span>
)
: (
<span className="yst-flex yst-justify-end">
<Button
variant="tertiary"
size="small"
className="yst--me-2.5"
onClick={ handleEdit }
aria-label={ editLabel }
>
{ __( "Edit", "wordpress-seo" ) }
</Button>
</span>
)
}
</Table.Cell>
</Table.Row>
);
Expand Down
Loading
Loading