diff --git a/packages/dataviews/src/components/dataform/index.tsx b/packages/dataviews/src/components/dataform/index.tsx index b359ddba74381..b96df5f186c75 100644 --- a/packages/dataviews/src/components/dataform/index.tsx +++ b/packages/dataviews/src/components/dataform/index.tsx @@ -10,8 +10,44 @@ import type { DataFormProps } from '../../types'; import { DataFormProvider } from '../dataform-context'; import { normalizeFields } from '../../normalize-fields'; import { DataFormLayout } from '../../dataforms-layouts/data-form-layout'; +import { MIXED_VALUE } from '../../constants'; -export default function DataForm< Item >( { +/** + * Loops through the list of data items and returns an object with the intersecting ( same ) key and values. + * Skips keys that start with an underscore. + * + * @param data list of items. + */ +function getIntersectingValues< Item extends object >( data: Item[] ): Item { + const intersectingValues = {} as Item; + const keys = Object.keys( data[ 0 ] ) as Array< keyof Item >; + for ( const key of keys ) { + // Skip keys that start with underscore. + if ( key.toString().startsWith( '_' ) ) { + continue; + } + const [ firstRecord, ...remainingRecords ] = data; + + if ( typeof firstRecord[ key ] === 'object' ) { + // Traverse through nested objects. + intersectingValues[ key ] = getIntersectingValues( + data.map( ( item ) => item[ key ] as object ) + ) as Item[ keyof Item ]; + } else { + const intersects = remainingRecords.every( ( item ) => { + return item[ key ] === firstRecord[ key ]; + } ); + if ( intersects ) { + intersectingValues[ key ] = firstRecord[ key ]; + } else { + intersectingValues[ key ] = MIXED_VALUE as Item[ keyof Item ]; + } + } + } + return intersectingValues; +} + +export default function DataForm< Item extends object >( { data, form, fields, @@ -22,13 +58,27 @@ export default function DataForm< Item >( { [ fields ] ); + const flattenedData = useMemo( () => { + if ( Array.isArray( data ) ) { + return getIntersectingValues< Item >( data ); + } + return data; + }, [ data ] ); + if ( ! form.fields ) { return null; } + const isBulkEditing = Array.isArray( data ); + return ( - + ); } diff --git a/packages/dataviews/src/constants.ts b/packages/dataviews/src/constants.ts index 5ae94c7eb4a13..fa5a9cd179de9 100644 --- a/packages/dataviews/src/constants.ts +++ b/packages/dataviews/src/constants.ts @@ -68,3 +68,6 @@ export const sortIcons = { export const LAYOUT_TABLE = 'table'; export const LAYOUT_GRID = 'grid'; export const LAYOUT_LIST = 'list'; + +// Dataform mixed value. +export const MIXED_VALUE = Symbol.for( 'DATAFORM_MIXED_VALUE' ); diff --git a/packages/dataviews/src/dataforms-layouts/data-form-layout.tsx b/packages/dataviews/src/dataforms-layouts/data-form-layout.tsx index 08cc47f569eaf..55542d434cb7a 100644 --- a/packages/dataviews/src/dataforms-layouts/data-form-layout.tsx +++ b/packages/dataviews/src/dataforms-layouts/data-form-layout.tsx @@ -7,17 +7,37 @@ import { useContext, useMemo } from '@wordpress/element'; /** * Internal dependencies */ -import type { Form, FormField, SimpleFormField } from '../types'; +import type { + CombinedFormField, + Form, + FormField, + NormalizedField, + SimpleFormField, +} from '../types'; import { getFormFieldLayout } from './index'; import DataFormContext from '../components/dataform-context'; import { isCombinedField } from './is-combined-field'; import normalizeFormFields from '../normalize-form-fields'; -export function DataFormLayout< Item >( { +function doesCombinedFieldSupportBulkEdits< Item >( + combinedField: CombinedFormField, + fieldDefinitions: NormalizedField< Item >[] +): boolean { + return combinedField.children.some( ( child ) => { + const fieldId = typeof child === 'string' ? child : child.id; + + return fieldDefinitions.find( + ( fieldDefinition ) => fieldDefinition.id === fieldId + )?.supportsBulkEditing; + } ); +} + +export function DataFormLayout< Item extends object >( { data, form, onChange, children, + isBulkEditing, }: { data: Item; form: Form; @@ -31,6 +51,7 @@ export function DataFormLayout< Item >( { } ) => React.JSX.Element | null, field: FormField ) => React.JSX.Element; + isBulkEditing?: boolean; } ) { const { fields: fieldDefinitions } = useContext( DataFormContext ); @@ -69,6 +90,19 @@ export function DataFormLayout< Item >( { return null; } + if ( + isBulkEditing && + ( ( isCombinedField( formField ) && + ! doesCombinedFieldSupportBulkEdits( + formField, + fieldDefinitions + ) ) || + ( fieldDefinition && + ! fieldDefinition.supportsBulkEditing ) ) + ) { + return null; + } + if ( children ) { return children( FieldLayout, formField ); } @@ -79,6 +113,7 @@ export function DataFormLayout< Item >( { data={ data } field={ formField } onChange={ onChange } + isBulkEditing={ isBulkEditing } /> ); } ) } diff --git a/packages/dataviews/src/dataforms-layouts/panel/index.tsx b/packages/dataviews/src/dataforms-layouts/panel/index.tsx index 269b2bb418a85..f7f70d8571c61 100644 --- a/packages/dataviews/src/dataforms-layouts/panel/index.tsx +++ b/packages/dataviews/src/dataforms-layouts/panel/index.tsx @@ -26,6 +26,7 @@ import type { import DataFormContext from '../../components/dataform-context'; import { DataFormLayout } from '../data-form-layout'; import { isCombinedField } from '../is-combined-field'; +import { MIXED_VALUE } from '../../constants'; function DropdownHeader( { title, @@ -59,13 +60,14 @@ function DropdownHeader( { ); } -function PanelDropdown< Item >( { +function PanelDropdown< Item extends object >( { fieldDefinition, popoverAnchor, labelPosition = 'side', data, onChange, field, + isBulkEditing, }: { fieldDefinition: NormalizedField< Item >; popoverAnchor: HTMLElement | null; @@ -73,6 +75,7 @@ function PanelDropdown< Item >( { data: Item; onChange: ( value: any ) => void; field: FormField; + isBulkEditing?: boolean; } ) { const fieldLabel = isCombinedField( field ) ? field.label @@ -111,6 +114,9 @@ function PanelDropdown< Item >( { [ popoverAnchor ] ); + const fieldValue = fieldDefinition.getValue( { item: data } ); + const showMixedValue = isBulkEditing && fieldValue === MIXED_VALUE; + return ( ( { ) } onClick={ onToggle } > - + { showMixedValue ? ( + __( 'Mixed' ) + ) : ( + + ) } ) } renderContent={ ( { onClose } ) => ( @@ -148,6 +158,7 @@ function PanelDropdown< Item >( { data={ data } form={ form as Form } onChange={ onChange } + isBulkEditing={ isBulkEditing } > { ( FieldLayout, nestedField ) => ( ( { data, field, onChange, + isBulkEditing, }: FieldLayoutProps< Item > ) { const { fields } = useContext( DataFormContext ); const fieldDefinition = fields.find( ( fieldDef ) => { @@ -221,6 +233,7 @@ export default function FormPanelField< Item >( { data={ data } onChange={ onChange } labelPosition={ labelPosition } + isBulkEditing={ isBulkEditing } /> @@ -237,6 +250,7 @@ export default function FormPanelField< Item >( { data={ data } onChange={ onChange } labelPosition={ labelPosition } + isBulkEditing={ isBulkEditing } /> ); @@ -259,6 +273,7 @@ export default function FormPanelField< Item >( { data={ data } onChange={ onChange } labelPosition={ labelPosition } + isBulkEditing={ isBulkEditing } /> diff --git a/packages/dataviews/src/dataforms-layouts/regular/index.tsx b/packages/dataviews/src/dataforms-layouts/regular/index.tsx index a3d90b807b5cd..aa47d36e56339 100644 --- a/packages/dataviews/src/dataforms-layouts/regular/index.tsx +++ b/packages/dataviews/src/dataforms-layouts/regular/index.tsx @@ -30,7 +30,7 @@ function Header( { title }: { title: string } ) { ); } -export default function FormRegularField< Item >( { +export default function FormRegularField< Item extends object >( { data, field, onChange, diff --git a/packages/dataviews/src/index.ts b/packages/dataviews/src/index.ts index 4ca0cf3db0bd3..bb1a8c21c98ed 100644 --- a/packages/dataviews/src/index.ts +++ b/packages/dataviews/src/index.ts @@ -4,3 +4,4 @@ export { VIEW_LAYOUTS } from './dataviews-layouts'; export { filterSortAndPaginate } from './filter-and-sort-data-view'; export type * from './types'; export { isItemValid } from './validation'; +export { MIXED_VALUE as DATAFORM_MIXED_VALUE } from './constants'; diff --git a/packages/dataviews/src/normalize-fields.ts b/packages/dataviews/src/normalize-fields.ts index 2ed87cbe11222..c32fcf81e9ae7 100644 --- a/packages/dataviews/src/normalize-fields.ts +++ b/packages/dataviews/src/normalize-fields.ts @@ -64,6 +64,12 @@ export function normalizeFields< Item >( ); }; + let supportsBulkEditing = true; + // If custom Edit component is passed in we default to false for bulk edit support. + if ( typeof field.Edit === 'function' || field.supportsBulkEditing ) { + supportsBulkEditing = field.supportsBulkEditing ?? false; + } + const render = field.render || ( field.elements ? renderFromElements : getValue ); @@ -76,6 +82,7 @@ export function normalizeFields< Item >( sort, isValid, Edit, + supportsBulkEditing, enableHiding: field.enableHiding ?? true, enableSorting: field.enableSorting ?? true, }; diff --git a/packages/dataviews/src/types.ts b/packages/dataviews/src/types.ts index 0bce8b8cf2c08..b05e13ae5dffc 100644 --- a/packages/dataviews/src/types.ts +++ b/packages/dataviews/src/types.ts @@ -158,6 +158,11 @@ export type Field< Item > = { * Defaults to `item[ field.id ]`. */ getValue?: ( args: { item: Item } ) => any; + + /** + * Whether the field supports bulk editing. + */ + supportsBulkEditing?: boolean; }; export type NormalizedField< Item > = Field< Item > & { @@ -564,7 +569,7 @@ export type Form = { }; export interface DataFormProps< Item > { - data: Item; + data: Item | Item[]; fields: Field< Item >[]; form: Form; onChange: ( value: Record< string, any > ) => void; @@ -575,4 +580,5 @@ export interface FieldLayoutProps< Item > { field: FormField; onChange: ( value: any ) => void; hideLabelFromVision?: boolean; + isBulkEditing?: boolean; } diff --git a/packages/edit-site/src/components/post-edit/index.js b/packages/edit-site/src/components/post-edit/index.js index 3e75ef71d1ac9..70ba9d230548e 100644 --- a/packages/edit-site/src/components/post-edit/index.js +++ b/packages/edit-site/src/components/post-edit/index.js @@ -11,7 +11,7 @@ import { DataForm } from '@wordpress/dataviews'; import { useDispatch, useSelect } from '@wordpress/data'; import { store as coreDataStore } from '@wordpress/core-data'; import { __experimentalVStack as VStack } from '@wordpress/components'; -import { useState, useMemo, useEffect } from '@wordpress/element'; +import { useMemo } from '@wordpress/element'; import { privateApis as editorPrivateApis } from '@wordpress/editor'; /** @@ -22,17 +22,30 @@ import { unlock } from '../../lock-unlock'; const { PostCardPanel, usePostFields } = unlock( editorPrivateApis ); -const fieldsWithBulkEditSupport = [ - 'title', - 'status', - 'date', - 'author', - 'comment_status', -]; +const DATAFORM_CONFIG = { + type: 'panel', + fields: [ + { + id: 'featured_media', + layout: 'regular', + }, + 'title', + { + id: 'status', + label: __( 'Status & Visibility' ), + children: [ 'status', 'password' ], + }, + 'author', + 'date', + 'slug', + 'parent', + 'comment_status', + ], +}; function PostEditForm( { postType, postId } ) { const ids = useMemo( () => postId.split( ',' ), [ postId ] ); - const { record } = useSelect( + const { record, records } = useSelect( ( select ) => { return { record: @@ -43,11 +56,20 @@ function PostEditForm( { postType, postId } ) { ids[ 0 ] ) : null, + records: + ids.length > 1 + ? ids.map( ( id ) => + select( coreDataStore ).getEditedEntityRecord( + 'postType', + postType, + id + ) + ) + : null, }; }, [ postType, ids ] ); - const [ multiEdits, setMultiEdits ] = useState( {} ); const { editEntityRecord } = useDispatch( coreDataStore ); const { fields: _fields } = usePostFields( { postType } ); const fields = useMemo( @@ -66,33 +88,6 @@ function PostEditForm( { postType, postId } ) { [ _fields ] ); - const form = useMemo( - () => ( { - type: 'panel', - fields: [ - { - id: 'featured_media', - layout: 'regular', - }, - 'title', - { - id: 'status', - label: __( 'Status & Visibility' ), - children: [ 'status', 'password' ], - }, - 'author', - 'date', - 'slug', - 'parent', - 'comment_status', - ].filter( - ( field ) => - ids.length === 1 || - fieldsWithBulkEditSupport.includes( field ) - ), - } ), - [ ids ] - ); const onChange = ( edits ) => { for ( const id of ids ) { if ( @@ -111,17 +106,8 @@ function PostEditForm( { postType, postId } ) { edits.password = ''; } editEntityRecord( 'postType', postType, id, edits ); - if ( ids.length > 1 ) { - setMultiEdits( ( prev ) => ( { - ...prev, - ...edits, - } ) ); - } } }; - useEffect( () => { - setMultiEdits( {} ); - }, [ ids ] ); return ( @@ -129,9 +115,9 @@ function PostEditForm( { postType, postId } ) { ) } diff --git a/packages/fields/src/fields/title/index.ts b/packages/fields/src/fields/title/index.ts index d8e6f25276d6b..7666710012ac3 100644 --- a/packages/fields/src/fields/title/index.ts +++ b/packages/fields/src/fields/title/index.ts @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import type { Field } from '@wordpress/dataviews'; +import { type Field } from '@wordpress/dataviews'; import { __ } from '@wordpress/i18n'; /** @@ -16,7 +16,12 @@ const titleField: Field< BasePost > = { id: 'title', label: __( 'Title' ), placeholder: __( 'No title' ), - getValue: ( { item } ) => getItemTitle( item ), + getValue: ( { item } ) => { + if ( typeof item.title === 'symbol' ) { + return item.title; + } + return getItemTitle( item ); + }, render: TitleView, enableHiding: false, };