5

I managed to somewhat replicate the product gallery metabox from WooCommerce in Gutenberg using the third-party react-sortable-hoc component. As it seems that this component is no longer supported, I'd like to know if one can get the same drag-to-sort functionality using core Gutenberg components only as I prefer to not rely on third-party components for this. Is the core Draggable component an equivalent?

This is the code I got right now:

index.js

const { __ } = wp.i18n;
const {
    BaseControl,
    Button,
    withNotices,
} = wp.components;
const { compose } = wp.compose;
const { withSelect, withDispatch } = wp.data;
const { MediaUpload, MediaUploadCheck }  = wp.blockEditor;

import Sortable from './sortable';
import FeaturedGalleryImage from './featured-gallery-image';

const ALLOWED_MEDIA_TYPES = [ 'image' ];

function FeaturedGallery( {
    currentPostId,
    featuredGalleryIds,
    onUpdateGallery,
    onSortGallery,
    onClearGallery,
    noticeUI,
} ) {
    const instructions = (
        <p>
            { __(
                'To edit the featured gallery, you need permission to upload media.', 'my-featured-gallery'
            ) }
        </p>
    );

    const hasImages = !! featuredGalleryIds.length;

    return (
        <BaseControl
            className="my-featured-gallery"
        >
            { noticeUI }
            <div className="editor-post-featured-gallery">
                <div className="editor-post-featured-gallery__container">
                    { hasImages && (
                        <Sortable
                            className="featured-gallery-grid"
                            items={ featuredGalleryIds }
                            axis="grid"
                            onSortEnd={ onSortGallery }
                        >
                            { featuredGalleryIds.map( ( img ) => (
                                <li key={ img } tabIndex={0}>
                                    <FeaturedGalleryImage
                                        id={ img }
                                    />
                                </li>
                            ) ) }
                        </Sortable>
                    ) }
                </div>
                <MediaUploadCheck fallback={ instructions }>
                    <MediaUpload
                        title={ __( 'Featured gallery', 'my-featured-gallery' ) }
                        multiple
                        onSelect={ onUpdateGallery }
                        allowedTypes={ ALLOWED_MEDIA_TYPES }
                        value={ hasImages ? featuredGalleryIds : [] }
                        render={ ( { open } ) => (
                                <Button
                                    className={
                                        hasImages
                                            ? 'editor-post-featured-gallery__edit'
                                            : 'editor-post-featured-gallery__add'
                                    }
                                    onClick={ open }
                                    isSecondary
                                >
                                    {
                                        hasImages
                                            ? __( 'Edit gallery', 'my-featured-gallery' )
                                            : __( 'Add to gallery', 'my-featured-gallery' )
                                    }
                                </Button>
                        ) }
                    />
                </MediaUploadCheck>                
                { hasImages && (
                    <MediaUploadCheck>
                        <Button onClick={ onClearGallery } isLink isDestructive>
                            { 
                                __( 'Clear gallery', 'my-featured-gallery' )
                            }
                        </Button>
                    </MediaUploadCheck>
                ) }
            </div>
        </BaseControl>            
    );
}

const applyWithSelect = withSelect( ( select ) => {
    const { getPostType } = select( 'core' );
    const { getCurrentPostId, getEditedPostAttribute } = select(
        'core/editor'
    );
    const meta = getEditedPostAttribute( 'meta' );
    const featuredGalleryIds = meta._featured_gallery;
    
    return {
        currentPostId: getCurrentPostId(),
        postType: getPostType( getEditedPostAttribute( 'type' ) ),
        featuredGalleryIds,
    };
} );

const applyWithDispatch = withDispatch( ( dispatch ) => {
        const { editPost } = dispatch( 'core/editor' );
        return {
            onUpdateGallery( images ) {
                const items = images.map( ( item ) => item.id );
                const meta = { _featured_gallery: items };
                editPost( { meta } );
            },
            onSortGallery( images ) {
                const meta = { _featured_gallery: images };
                editPost( { meta } );
            },
            onClearGallery() {
                const meta = { _featured_gallery: [] };
                editPost( { meta } );
            }
        };
    }
);

export default compose(
    withNotices,
    applyWithSelect,
    applyWithDispatch,
)( FeaturedGallery );

featured-gallery-image.js

const { __ } = wp.i18n;
const { withSelect } = wp.data;
const { Spinner } = wp.components;
const { compose } = wp.compose;

function FeaturedGalleryImage( {
    id,
    image,
} ) {

    return (
        <figure>
            { image && (
                <img
                    src={ image.media_details.sizes.thumbnail.source_url }
                    width={ image.media_details.sizes.thumbnail.width }
                    height={ image.media_details.sizes.thumbnail.height }
                    alt={ __( 'Thumbnail of the image.', 'my-featured-gallery' ) }
                />
            ) }
            { ! image && (
                <Spinner />
            ) }
        </figure>
    );

}

const applyWithSelect = withSelect( ( select, ownProps ) => {
    const { getMedia } = select( 'core' );
    const { id } = ownProps;
    return {
        image: id ? getMedia( parseInt( id, 10 ) ) : null,
    };
} );
    
export default compose( [
    applyWithSelect
] )( FeaturedGalleryImage );

sortable.js

import { Component } from '@wordpress/element';
import { SortableContainer, SortableElement, arrayMove } from 'react-sortable-hoc';

const SortableItem = SortableElement( ( { children } ) => children );

class Sortable extends Component {
    constructor() {
        super( ...arguments );

        this.onSortStart = this.onSortStart.bind( this );
        this.onSortEnd = this.onSortEnd.bind( this );
        this.state = {
            justSorted: false,
        };
    }

    /**
     * Get the sortable list:
     * @return {function} the Container creator
     */
    getSortableList() {
        const { children, className, firstNode, lastNode } = this.props;

        // Create the sortable container
        return SortableContainer( () => {
            // Loop through all available children
            return (
                <ul className={ `components-sortable ${ className }` }>
                    { firstNode }
                    { children.map( ( child, index ) => {
                        // Display Sortable element
                        return (
                            <SortableItem key={ `item-${ index }` } index={ index }>
                                { child }
                            </SortableItem>
                        );
                    } ) }
                    { lastNode }
                </ul>
            );
        } );
    }

    shouldComponentUpdate( nextProps ) {
        const should = nextProps.items.every( ( item, index ) => {
            return this.props.items[ index ] === item;
        } );
        const justSorted = this.state.justSorted;
        if ( justSorted ) {
            this.setState( {
                justSorted: false,
            } );
        }
        const lengthChanged = nextProps.items.length !== this.props.items.length;

        return should || justSorted || lengthChanged;
    }

    render() {
        const items = this.props.items;
        const SortableList = this.getSortableList();

        return (
            // Return the sortable list, with props from our upper-lever component
            <SortableList
                axis="xy"
                items={ items }
                onSortStart={ this.onSortStart }
                onSortEnd={ this.onSortEnd }
                distance={ 0 }
                helperClass={ 'dragged-element' }
            />
        );
    }

    /**
     * What to do on sort start ?
     * @param {Object} object with access to the element, its index and the collection
     * @param {Event} event with the event info
     */
    onSortStart( { node, index, collection }, event ) {
        // Run the corresponding function in the upper-lever component
        if ( typeof ( this.props.onSortStart ) === 'function' ) {
            this.props.onSortStart( { node, index, collection }, event );
        }
    }

    /**
     * What to do on sort end?
     *
     * @param {Object} object holding old and new indexes and the collection
     */
    onSortEnd( { oldIndex, newIndex } ) {
        // Create a new items array
        const _items = arrayMove( this.props.items, oldIndex, newIndex );
        this.setState( {
            justSorted: true,
        } );

        // And run the corresponding function in the upper-lever component:
        if ( typeof ( this.props.onSortEnd ) === 'function' ) {
            this.props.onSortEnd( _items );
        }
    }
}

export default Sortable;

0