diff --git a/packages/block-library/CHANGELOG.md b/packages/block-library/CHANGELOG.md index db8a2770767fc6..53f8ebfd4cf4da 100644 --- a/packages/block-library/CHANGELOG.md +++ b/packages/block-library/CHANGELOG.md @@ -15,6 +15,7 @@ ### Enhancements +- Playlist Track: Enable editing artist, album, and track image for multiple selected tracks. - Playlist Track: Use a dedicated icon for the block toolbar. ([#80959](https://github.com/WordPress/gutenberg/pull/80959)) - Playlist: Expose the parent "Add track" toolbar control to selected Playlist Track child blocks via block toolbar sharing ([#80368](https://github.com/WordPress/gutenberg/pull/80368)). - Playlist: Allow selecting audio tracks individually in the Media Library without holding Shift or Command, transform multiple Audio blocks into a Playlist, and transform a one-track Playlist into Audio. ([#80926](https://github.com/WordPress/gutenberg/pull/80926)) diff --git a/packages/block-library/src/playlist-track/edit.js b/packages/block-library/src/playlist-track/edit.js index 5e333bb1147c16..549299ffe3b88e 100644 --- a/packages/block-library/src/playlist-track/edit.js +++ b/packages/block-library/src/playlist-track/edit.js @@ -6,6 +6,7 @@ import { MediaUpload, MediaUploadCheck, BlockIcon, + store as blockEditorStore, useBlockProps, BlockControls, InspectorControls, @@ -20,7 +21,7 @@ import { Spinner, } from '@wordpress/components'; import { Link } from '@wordpress/ui'; -import { useDispatch } from '@wordpress/data'; +import { useDispatch, useSelect } from '@wordpress/data'; import { store as noticesStore } from '@wordpress/notices'; import { __ } from '@wordpress/i18n'; import { audio as icon } from '@wordpress/icons'; @@ -31,6 +32,149 @@ import { useUploadMediaFromBlobURL } from '../utils/hooks'; const ALLOWED_MEDIA_TYPES = [ 'audio' ]; const TRACK_IMAGE_ALLOWED_MEDIA_TYPES = [ 'image' ]; +const EMPTY_SELECTED_TRACKS = { + selectedTrackClientIds: [], + selectedTracks: [], +}; + +function getSharedTrackAttribute( tracks, attribute ) { + if ( tracks.length === 0 ) { + return ''; + } + + const firstValue = tracks[ 0 ]?.attributes?.[ attribute ] || ''; + const hasSharedValue = tracks.every( + ( track ) => ( track.attributes?.[ attribute ] || '' ) === firstValue + ); + + return hasSharedValue ? firstValue : ''; +} + +function TrackImageControl( { + image, + hasImage, + imageAlt, + onSelectImage, + onRemoveImage, + onChangeImageAlt, + imageButtonRef, +} ) { + return ( + <> + + + + { __( 'Track image' ) } + +
+ { !! image && ( + { + ) } + ( + + ) } + /> + { hasImage && ( + + ) } +
+
+
+ { hasImage && ( + + { __( 'Describe the purpose of the image.' ) } + + } + /> + ) } + + ); +} + +function TrackInspectorControls( { + panelTitle, + artist, + onChangeArtist, + album, + onChangeAlbum, + title, + onChangeTitle, + image, + hasImage, + imageAlt, + onSelectImage, + onRemoveImage, + onChangeImageAlt, + imageButtonRef, +} ) { + return ( + + + { onChangeTitle && ( + + ) } + + + + + + ); +} const PlaylistTrackEdit = ( { attributes, @@ -49,10 +193,72 @@ const PlaylistTrackEdit = ( { const { currentTrackClientId, setCurrentTrackClientId } = useContext( PlaylistContext ); const { createErrorNotice } = useDispatch( noticesStore ); + const { updateBlockAttributes } = useDispatch( blockEditorStore ); + const { selectedTrackClientIds, selectedTracks } = useSelect( + ( select ) => { + const { + getBlockName, + getBlockRootClientId, + getMultiSelectedBlockClientIds, + getMultiSelectedBlocks, + } = select( blockEditorStore ); + const multiSelectedClientIds = getMultiSelectedBlockClientIds(); + const playlistClientId = getBlockRootClientId( clientId ); + + if ( multiSelectedClientIds.length <= 1 || ! playlistClientId ) { + return EMPTY_SELECTED_TRACKS; + } + + const isSelectingPlaylistTracks = multiSelectedClientIds.every( + ( selectedClientId ) => + getBlockName( selectedClientId ) === + 'core/playlist-track' && + getBlockRootClientId( selectedClientId ) === + playlistClientId + ); + + if ( ! isSelectingPlaylistTracks ) { + return EMPTY_SELECTED_TRACKS; + } + + const multiSelectedTracks = getMultiSelectedBlocks(); + if ( multiSelectedTracks.some( ( track ) => ! track ) ) { + return EMPTY_SELECTED_TRACKS; + } + + return { + selectedTrackClientIds: multiSelectedClientIds, + selectedTracks: multiSelectedTracks, + }; + }, + [ clientId ] + ); function onUploadError( message ) { createErrorNotice( message, { type: 'snackbar' } ); } const hasTrackSource = !! src || !! temporaryURL; + const hasSelectedTracks = selectedTrackClientIds.length > 1; + const isEditingSelectedTracks = + hasSelectedTracks && selectedTrackClientIds[ 0 ] === clientId; + const selectedTracksHaveImage = selectedTracks.some( + ( track ) => !! track.attributes?.image + ); + const selectedTrackArtist = getSharedTrackAttribute( + selectedTracks, + 'artist' + ); + const selectedTrackAlbum = getSharedTrackAttribute( + selectedTracks, + 'album' + ); + const selectedTrackImage = getSharedTrackAttribute( + selectedTracks, + 'image' + ); + const selectedTrackImageAlt = getSharedTrackAttribute( + selectedTracks, + 'imageAlt' + ); useEffect( () => { if ( @@ -121,6 +327,31 @@ const PlaylistTrackEdit = ( { imageButton.current.focus(); } + function updateSelectedTracksAttribute( attribute ) { + return ( value ) => { + updateBlockAttributes( selectedTrackClientIds, { + [ attribute ]: value, + } ); + }; + } + + function onSelectSelectedTrackImage( trackImage ) { + updateBlockAttributes( + selectedTrackClientIds, + getTrackImageAttributes( trackImage ) + ); + } + + function onRemoveSelectedTrackImage() { + updateBlockAttributes( selectedTrackClientIds, { + image: undefined, + imageAlt: undefined, + } ); + + // Move focus back to the Media Upload button. + imageButton.current?.focus(); + } + if ( ! hasTrackSource ) { return (
@@ -156,100 +387,50 @@ const PlaylistTrackEdit = ( { variant="toolbar" /> - - - { - setAttributes( { artist: artistValue } ); - } } - /> - { - setAttributes( { album: albumValue } ); - } } - /> - { - setAttributes( { title: titleValue } ); - } } - /> - - - - { __( 'Track image' ) } - -
- { !! image && ( - { - ) } - ( - - ) } - /> - { !! image && ( - - ) } -
-
-
- { !! image && ( - - setAttributes( { imageAlt: value } ) - } - help={ - - { __( - 'Describe the purpose of the image.' - ) } - - } - /> + { isEditingSelectedTracks && ( + -
+ imageButtonRef={ imageButton } + /> + ) } + { ! hasSelectedTracks && ( + { + setAttributes( { artist: artistValue } ); + } } + album={ album } + onChangeAlbum={ ( albumValue ) => { + setAttributes( { album: albumValue } ); + } } + title={ title } + onChangeTitle={ ( titleValue ) => { + setAttributes( { title: titleValue } ); + } } + image={ image } + hasImage={ !! image } + imageAlt={ imageAlt || '' } + onSelectImage={ onSelectTrackImage } + onRemoveImage={ onRemoveTrackImage } + onChangeImageAlt={ ( value ) => + setAttributes( { imageAlt: value } ) + } + imageButtonRef={ imageButton } + /> + ) }
  • { !! temporaryURL && } ; }, - MediaUpload: ( { render: renderMediaUpload } ) => - renderMediaUpload( { open: jest.fn() } ), + MediaUpload: ( props ) => { + mockMediaUploadProps.push( props ); + return ( +
    + { props.render( { open: jest.fn() } ) } +
    + ); + }, MediaUploadCheck: ( { children } ) =>
    { children }
    , PlainText: ( { onChange, @@ -32,6 +41,7 @@ jest.mock( '@wordpress/block-editor', () => ( { jest.mock( '@wordpress/data', () => ( { useDispatch: jest.fn(), + useSelect: jest.fn(), combineReducers: jest.fn( ( reducers ) => ( state = {}, action ) => { const newState = {}; Object.keys( reducers ).forEach( ( key ) => { @@ -96,14 +106,38 @@ function renderEdit( props = {} ) { } describe( 'PlaylistTrackEdit', () => { + let updateBlockAttributes; + beforeEach( () => { mockMediaReplaceFlowProps = undefined; - useDispatch.mockReturnValue( { - createErrorNotice: jest.fn(), + mockMediaUploadProps = []; + updateBlockAttributes = jest.fn(); + useDispatch.mockImplementation( ( store ) => { + if ( store === blockEditorStore ) { + return { updateBlockAttributes }; + } + + return { createErrorNotice: jest.fn() }; + } ); + useSelect.mockReturnValue( { + selectedTrackClientIds: [], + selectedTracks: [], } ); useUploadMediaFromBlobURL.mockClear(); } ); + it( 'shows the title before artist and album controls', () => { + renderEdit(); + + const formFields = screen.getAllByRole( 'textbox' ); + + expect( formFields.slice( 0, 3 ) ).toEqual( [ + screen.getByRole( 'textbox', { name: 'Title' } ), + screen.getByRole( 'textbox', { name: 'Artist' } ), + screen.getByRole( 'textbox', { name: 'Album' } ), + ] ); + } ); + it( 'allows the track image alternative text to be edited', () => { const { setAttributes } = renderEdit(); @@ -209,4 +243,119 @@ describe( 'PlaylistTrackEdit', () => { } ) ); } ); + + it( 'allows artist and album to be edited for multiple selected tracks', () => { + const selectedTrackClientIds = [ + 'track-client-id-1', + 'track-client-id-2', + ]; + useSelect.mockReturnValue( { + selectedTrackClientIds, + selectedTracks: [ + { + clientId: 'track-client-id-1', + attributes: { + artist: 'The Artist', + album: 'Great Album', + }, + }, + { + clientId: 'track-client-id-2', + attributes: { + artist: 'The Artist', + album: 'Great Album', + }, + }, + ], + } ); + + renderEdit( { + clientId: 'track-client-id-1', + } ); + + expect( screen.getByText( 'Selected tracks' ) ).toBeInTheDocument(); + + fireEvent.change( screen.getByLabelText( 'Artist' ), { + target: { value: 'Shared Artist' }, + } ); + fireEvent.change( screen.getByLabelText( 'Album' ), { + target: { value: 'Shared Album' }, + } ); + + expect( updateBlockAttributes ).toHaveBeenCalledWith( + selectedTrackClientIds, + { artist: 'Shared Artist' } + ); + expect( updateBlockAttributes ).toHaveBeenCalledWith( + selectedTrackClientIds, + { album: 'Shared Album' } + ); + expect( screen.queryByLabelText( 'Title' ) ).not.toBeInTheDocument(); + } ); + + it( 'allows track images to be edited for multiple selected tracks', () => { + const selectedTrackClientIds = [ + 'track-client-id-1', + 'track-client-id-2', + ]; + useSelect.mockReturnValue( { + selectedTrackClientIds, + selectedTracks: [ + { + clientId: 'track-client-id-1', + attributes: { + image: 'https://example.com/old-cover.jpg', + imageAlt: 'Old cover', + }, + }, + { + clientId: 'track-client-id-2', + attributes: { + image: 'https://example.com/old-cover.jpg', + imageAlt: 'Old cover', + }, + }, + ], + } ); + + renderEdit( { + clientId: 'track-client-id-1', + } ); + + const trackImageUpload = screen.getByTestId( 'media-upload' ); + const replaceTrackImageButton = within( trackImageUpload ).getByRole( + 'button', + { + name: 'Replace', + } + ); + + mockMediaUploadProps[ 0 ].onSelect( { + url: 'https://example.com/new-cover.jpg', + alt: 'New cover', + } ); + + expect( updateBlockAttributes ).toHaveBeenCalledWith( + selectedTrackClientIds, + { + image: 'https://example.com/new-cover.jpg', + imageAlt: 'New cover', + } + ); + + fireEvent.click( + screen.getByRole( 'button', { + name: 'Remove', + } ) + ); + + expect( updateBlockAttributes ).toHaveBeenCalledWith( + selectedTrackClientIds, + { + image: undefined, + imageAlt: undefined, + } + ); + expect( replaceTrackImageButton ).toHaveFocus(); + } ); } );