📖 WordPress Development - Gutenberg Blocks và REST API
85 phút

Gutenberg Blocks và REST API

Block Editor Basics

Setup plugin cho blocks

npx @wordpress/create-block my-blocks
cd my-blocks
npm start

Block registration

// src/block.json
{
    "$schema": "https://schemas.wp.org/trunk/block.json",
    "apiVersion": 3,
    "name": "my-plugin/hero",
    "version": "1.0.0",
    "title": "Hero Section",
    "category": "design",
    "icon": "cover-image",
    "description": "A hero section with title, subtitle, and button",
    "keywords": ["hero", "banner", "header"],
    "supports": {
        "html": false,
        "align": ["wide", "full"],
        "color": {
            "background": true,
            "text": true
        },
        "spacing": {
            "padding": true
        }
    },
    "attributes": {
        "title": {
            "type": "string",
            "default": "Welcome"
        },
        "subtitle": {
            "type": "string",
            "default": ""
        },
        "buttonText": {
            "type": "string",
            "default": "Learn More"
        },
        "buttonUrl": {
            "type": "string",
            "default": ""
        },
        "backgroundImage": {
            "type": "object",
            "default": null
        }
    },
    "textdomain": "my-plugin",
    "editorScript": "file:./index.js",
    "editorStyle": "file:./index.css",
    "style": "file:./style-index.css"
}

Edit component

// src/edit.js
import { __ } from '@wordpress/i18n';
import {
    useBlockProps,
    RichText,
    MediaUpload,
    MediaUploadCheck,
    InspectorControls,
} from '@wordpress/block-editor';
import {
    PanelBody,
    TextControl,
    Button,
} from '@wordpress/components';

export default function Edit({ attributes, setAttributes }) {
    const {
        title,
        subtitle,
        buttonText,
        buttonUrl,
        backgroundImage,
    } = attributes;

    const blockProps = useBlockProps();

    return (
        <>
            <InspectorControls>
                <PanelBody title={__('Button Settings', 'my-plugin')}>
                    <TextControl
                        label={__('Button URL', 'my-plugin')}
                        value={buttonUrl}
                        onChange={(value) => setAttributes({ buttonUrl: value })}
                    />
                </PanelBody>

                <PanelBody title={__('Background', 'my-plugin')}>
                    <MediaUploadCheck>
                        <MediaUpload
                            onSelect={(media) => setAttributes({
                                backgroundImage: { id: media.id, url: media.url }
                            })}
                            allowedTypes={['image']}
                            value={backgroundImage?.id}
                            render={({ open }) => (
                                <Button onClick={open} variant="secondary">
                                    {backgroundImage
                                        ? __('Replace Image', 'my-plugin')
                                        : __('Choose Image', 'my-plugin')}
                                </Button>
                            )}
                        />
                    </MediaUploadCheck>

                    {backgroundImage && (
                        <Button
                            onClick={() => setAttributes({ backgroundImage: null })}
                            variant="link"
                            isDestructive
                        >
                            {__('Remove Image', 'my-plugin')}
                        </Button>
                    )}
                </PanelBody>
            </InspectorControls>

            <div
                {...blockProps}
                style={{
                    backgroundImage: backgroundImage
                        ? `url(${backgroundImage.url})`
                        : 'none',
                    backgroundSize: 'cover',
                    backgroundPosition: 'center',
                }}
            >
                <div className="hero-overlay">
                    <RichText
                        tagName="h1"
                        value={title}
                        onChange={(value) => setAttributes({ title: value })}
                        placeholder={__('Enter title...', 'my-plugin')}
                        className="hero-title"
                    />

                    <RichText
                        tagName="p"
                        value={subtitle}
                        onChange={(value) => setAttributes({ subtitle: value })}
                        placeholder={__('Enter subtitle...', 'my-plugin')}
                        className="hero-subtitle"
                    />

                    <div className="hero-button">
                        <RichText
                            tagName="span"
                            value={buttonText}
                            onChange={(value) => setAttributes({ buttonText: value })}
                            className="button-text"
                        />
                    </div>
                </div>
            </div>
        </>
    );
}

Save component

// src/save.js
import { useBlockProps, RichText } from '@wordpress/block-editor';

export default function Save({ attributes }) {
    const {
        title,
        subtitle,
        buttonText,
        buttonUrl,
        backgroundImage,
    } = attributes;

    const blockProps = useBlockProps.save({
        style: {
            backgroundImage: backgroundImage
                ? `url(${backgroundImage.url})`
                : 'none',
            backgroundSize: 'cover',
            backgroundPosition: 'center',
        },
    });

    return (
        <div {...blockProps}>
            <div className="hero-overlay">
                <RichText.Content tagName="h1" value={title} className="hero-title" />
                <RichText.Content tagName="p" value={subtitle} className="hero-subtitle" />
                {buttonText && (
                    <a href={buttonUrl || '#'} className="hero-button">
                        <RichText.Content tagName="span" value={buttonText} />
                    </a>
                )}
            </div>
        </div>
    );
}

Block registration trong PHP

<?php
// my-plugin.php

function my_plugin_register_blocks() {
    register_block_type(__DIR__ . '/build/hero');
    register_block_type(__DIR__ . '/build/pricing-table');
}
add_action('init', 'my_plugin_register_blocks');

function my_plugin_enqueue_editor_assets() {
    wp_enqueue_script(
        'my-plugin-blocks',
        plugins_url('build/index.js', __FILE__),
        ['wp-blocks', 'wp-element', 'wp-editor', 'wp-components', 'wp-i18n'],
        filemtime(plugin_dir_path(__FILE__) . 'build/index.js')
    );
}
add_action('enqueue_block_editor_assets', 'my_plugin_enqueue_editor_assets');

Dynamic Blocks (PHP render)

// blocks/latest-books/block.json
{
    "name": "my-plugin/latest-books",
    "title": "Latest Books",
    "category": "widgets",
    "icon": "book",
    "attributes": {
        "count": { "type": "number", "default": 5 },
        "showExcerpt": { "type": "boolean", "default": true }
    },
    "render": "file:./render.php"
}

// blocks/latest-books/render.php
<?php
$count = $attributes['count'] ?? 5;
$show_excerpt = $attributes['showExcerpt'] ?? true;

$books = Book_Manager::get_all_books(['limit' => $count]);

$wrapper_attributes = get_block_wrapper_attributes([
    'class' => 'latest-books-block',
]);
?>
<div <?php echo $wrapper_attributes; ?>>
    <h2><?php esc_html_e('Latest Books', 'my-plugin'); ?></h2>
    <ul>
        <?php foreach ($books as $book): ?>
            <li>
                <strong><?php echo esc_html($book->title); ?></strong>
                <span>by <?php echo esc_html($book->author); ?></span>
                <?php if ($show_excerpt && $book->description): ?>
                    <p><?php echo esc_html(wp_trim_words($book->description, 20)); ?></p>
                <?php endif; ?>
            </li>
        <?php endforeach; ?>
    </ul>
</div>

Custom Block Variations

// src/variations.js
import { registerBlockVariation } from '@wordpress/blocks';

registerBlockVariation('my-plugin/hero', {
    name: 'hero-dark',
    title: 'Dark Hero',
    description: 'Hero with dark background',
    attributes: {
        backgroundColor: '#000000',
        textColor: '#ffffff',
    },
    isDefault: false,
});

Block Patterns

// register-patterns.php
add_action('init', function () {
    register_block_pattern_category('my-plugin', [
        'label' => __('My Plugin Patterns', 'my-plugin'),
    ]);

    register_block_pattern('my-plugin/hero-cta', [
        'title'       => __('Hero with CTA', 'my-plugin'),
        'description' => __('A hero section with call to action button', 'my-plugin'),
        'categories'  => ['my-plugin', 'call-to-action'],
        'content'     => '
            <!-- wp:my-plugin/hero -->
            <div class="wp-block-my-plugin-hero">
                <div class="hero-overlay">
                    <h1 class="hero-title">Build Amazing Things</h1>
                    <p class="hero-subtitle">Get started with our platform today</p>
                    <a href="#" class="hero-button"><span>Get Started</span></a>
                </div>
            </div>
            <!-- /wp:my-plugin/hero -->
        ',
    ]);
});

REST API Integration

// Trong block editor
import apiFetch from '@wordpress/api-fetch';

export default function Edit({ attributes, setAttributes }) {
    const [books, setBooks] = useState([]);
    const [loading, setLoading] = useState(true);

    useEffect(() => {
        apiFetch({ path: '/wp/v2/posts?per_page=5' })
            .then(posts => {
                setBooks(posts);
                setLoading(false);
            })
            .catch(err => {
                console.error(err);
                setLoading(false);
            });
    }, []);

    // ...
}

Block Transforms

// Từ paragraph sang hero
transforms: {
    from: [
        {
            type: 'block',
            blocks: ['core/paragraph'],
            transform: ({ content }) => {
                return createBlock('my-plugin/hero', {
                    title: content.replace(/<[^>]+>/g, ''),
                });
            },
        },
    ],
    to: [
        {
            type: 'block',
            blocks: ['core/paragraph'],
            transform: ({ title, subtitle }) => {
                return createBlock('core/paragraph', {
                    content: `<h1>${title}</h1><p>${subtitle}</p>`,
                });
            },
        },
    ],
}

Block Toolbar và Contextual Controls

import { BlockControls, AlignmentToolbar } from '@wordpress/block-editor';
import { ToolbarGroup, ToolbarButton } from '@wordpress/components';
import { formatBold } from '@wordpress/icons';

export default function Edit({ attributes, setAttributes }) {
    return (
        <>
            <BlockControls>
                <AlignmentToolbar
                    value={attributes.textAlign}
                    onChange={(value) => setAttributes({ textAlign: value })}
                />
                <ToolbarGroup>
                    <ToolbarButton
                        icon={formatBold}
                        label="Toggle bold"
                        onClick={() => setAttributes({ bold: !attributes.bold })}
                    />
                </ToolbarGroup>
            </BlockControls>

            {/* block content */}
        </>
    );
}

Inner Blocks

import { useInnerBlocksProps, useBlockProps } from '@wordpress/block-editor';

const TEMPLATE = [
    ['core/heading', { level: 2, placeholder: 'Section title' }],
    ['core/paragraph', { placeholder: 'Section content' }],
];

export default function Edit() {
    const blockProps = useBlockProps();
    const innerBlocksProps = useInnerBlocksProps(blockProps, {
        template: TEMPLATE,
        allowedBlocks: ['core/heading', 'core/paragraph', 'core/image'],
    });

    return <div {...innerBlocksProps} />;
}

WP REST API

Đăng ký custom endpoint

add_action('rest_api_init', function () {
    register_rest_route('my-plugin/v1', '/books', [
        [
            'methods'             => 'GET',
            'callback'            => 'my_plugin_rest_get_books',
            'permission_callback' => '__return_true',
            'args' => [
                'per_page' => [
                    'default'           => 10,
                    'sanitize_callback' => 'absint',
                ],
                'author' => [
                    'sanitize_callback' => 'sanitize_text_field',
                ],
            ],
        ],
        [
            'methods'             => 'POST',
            'callback'            => 'my_plugin_rest_create_book',
            'permission_callback' => function () {
                return current_user_can('edit_posts');
            },
        ],
    ]);

    register_rest_route('my-plugin/v1', '/books/(?P<id>\d+)', [
        'methods'             => 'DELETE',
        'callback'            => 'my_plugin_rest_delete_book',
        'permission_callback' => function () {
            return current_user_can('delete_posts');
        },
    ]);
});

function my_plugin_rest_get_books(WP_REST_Request $request): WP_REST_Response {
    $books = Book_Manager::get_all_books([
        'limit'  => $request->get_param('per_page'),
        'author' => $request->get_param('author') ?? '',
    ]);

    $response = new WP_REST_Response($books, 200);
    $response->header('X-Total-Count', count($books));
    return $response;
}

function my_plugin_rest_create_book(WP_REST_Request $request): WP_REST_Response|WP_Error {
    $title = $request->get_param('title');
    $author = $request->get_param('author');

    if (empty($title) || empty($author)) {
        return new WP_Error(
            'missing_data',
            __('Title and author are required.', 'my-plugin'),
            ['status' => 400]
        );
    }

    $id = Book_Manager::create_book([
        'title'  => sanitize_text_field($title),
        'author' => sanitize_text_field($author),
        'isbn'   => sanitize_text_field($request->get_param('isbn') ?? ''),
        'year'   => (int) ($request->get_param('year') ?? 0),
    ]);

    if (!$id) {
        return new WP_Error(
            'create_failed',
            __('Failed to create book.', 'my-plugin'),
            ['status' => 500]
        );
    }

    return new WP_REST_Response(Book_Manager::get_book($id), 201);
}

function my_plugin_rest_delete_book(WP_REST_Request $request): WP_REST_Response|WP_Error {
    $id = (int) $request['id'];

    if (!Book_Manager::get_book($id)) {
        return new WP_Error(
            'book_not_found',
            __('Book not found.', 'my-plugin'),
            ['status' => 404]
        );
    }

    Book_Manager::delete_book($id);
    return new WP_REST_Response(['deleted' => true], 200);
}

Custom post type REST support

register_post_type('book', [
    'public'       => true,
    'show_in_rest' => true,   // Enable REST API
    'rest_base'    => 'books',
    'rest_controller_class' => 'WP_REST_Posts_Controller',
    'supports'     => ['title', 'editor', 'thumbnail', 'custom-fields'],
    'taxonomies'   => ['genre', 'author'],
]);

// Custom REST field
add_action('rest_api_init', function () {
    register_rest_field('book', 'rating', [
        'get_callback' => function ($post_array) {
            return (float) get_post_meta($post_array['id'], 'rating', true);
        },
        'update_callback' => function ($value, $post) {
            update_post_meta($post->ID, 'rating', (float) $value);
        },
        'schema' => [
            'type'    => 'number',
            'minimum' => 0,
            'maximum' => 5,
        ],
    ]);
});

Bài tập thực hành

Hãy tạo custom Gutenberg block và REST API!

📝 Bài tập (1)

  1. Tạo block để hiển thị sách mới nhất