TypeScriptでWordPressのブロックを開発する「参考記事リンクブロック」パート2

参考サイト

ソースファイル

index.tsx

import { registerBlockType } from '@wordpress/blocks';
import { useBlockProps } from '@wordpress/block-editor';
import { TextControl, Button } from '@wordpress/components';
import { useSelect } from '@wordpress/data';
import { __ } from '@wordpress/i18n';
import { EntityRecord } from '@wordpress/core-data';
import { store as coreStore } from '@wordpress/core-data';
import React, { useState, useEffect } from 'react';

// Post 型の定義: WordPress の投稿データ構造を表現
type Post = EntityRecord & {
  id: number;
  title: {
    rendered: string;
  };
};

// WordPress データストアの型定義を拡張
declare module '@wordpress/data' {
  export function select(store: typeof coreStore): {
    // 複数のエンティティレコードを取得するための関数
    getEntityRecords<T extends EntityRecord>(
      kind: 'postType',
      name: string,
      queryArgs?: Record<string, any>
    ): T[] | null;
    // 単一のエンティティレコードを取得するための関数
    getEntityRecord<T extends EntityRecord>(
      kind: 'postType',
      name: string,
      id: number
    ): T | undefined;
    // タクソノミーのエンティティレコードを取得するための関数
    getEntityRecords<T extends EntityRecord>(
      kind: 'taxonomy',
      name: string,
      queryArgs?: Record<string, any>
    ): T[] | null;
  };
}

// ブロックの属性を定義するインターフェース
interface PostSelectorAttributes {
  selectedPostId: number;
  selectedCategoryId: number;
  searchText: string;
}

// カスタムブロックの登録
registerBlockType<PostSelectorAttributes>('customtheme/sample04', {
  title: __('Post Selector', 'my-customtheme'),
  icon: 'admin-post',
  category: 'widgets',

  // ブロックの属性を定義
  attributes: {
    selectedPostId: {
      type: 'number',
      default: 0,
    },
    selectedCategoryId: {
      type: 'number',
      default: 0,
    },
    searchText: {
      type: 'string',
      default: '',
    },
  },

  // ブロックのエディタ表示を定義
  edit: ({ attributes, setAttributes }) => {
    const blockProps = useBlockProps();
    // 検索テキストの状態を管理
    const [searchTerm, setSearchTerm] = useState(attributes.searchText);
  
    // カテゴリーの一覧を取得
    const categories = useSelect((select) => {
      return select(coreStore).getEntityRecords('taxonomy', 'category', { per_page: -1 });
    }, []);
  
    // 投稿一覧を取得(検索条件とカテゴリーでフィルタリング)
    const posts = useSelect((select) => {
      const queryArgs: Record<string, any> = {
        per_page: 10, // パフォーマンスのため、表示する投稿数を制限
        search: searchTerm,
      };
      if (attributes.selectedCategoryId !== 0) {
        queryArgs.categories = attributes.selectedCategoryId;
      }
      return select(coreStore).getEntityRecords<Post>('postType', 'post', queryArgs);
    }, [attributes.selectedCategoryId, searchTerm]);
  
    // 選択された投稿の詳細を取得
    const selectedPost = useSelect((select) => {
      return attributes.selectedPostId
        ? select(coreStore).getEntityRecord<Post>('postType', 'post', attributes.selectedPostId)
        : undefined;
    }, [attributes.selectedPostId]);
  
    // 投稿選択時の処理
    const onSelectPost = (postId: number) => {
      setAttributes({ selectedPostId: postId });
    };
  
    // カテゴリー選択時の処理
    const onSelectCategory = (categoryId: number) => {
      setAttributes({ selectedCategoryId: categoryId, selectedPostId: 0 });
    };

    // 検索テキスト変更時の処理
    const onSearchTextChange = (text: string) => {
      setSearchTerm(text);
    };

    // 検索テキストが変更されたら属性を更新
    useEffect(() => {
      setAttributes({ searchText: searchTerm });
    }, [searchTerm]);
  
    // ブロックのUI構造を返す
    return (
      <div {...blockProps} style={{
        border: '1px solid skyblue',
        padding: '1rem',
        marginTop: '4rem',
        position: 'relative'
      }}>
        {/* ブロックのタイトル */}
        <span style={{
          fontSize: '1rem',
          color: '#ffffff',
          position: 'absolute',
          lineHeight: '2rem',
          top: '-2rem',
          left: '-1px',
          backgroundColor: 'skyblue',
          padding: '0 1rem',
        }}>
          参考記事
        </span>
        <div style={{ marginBottom: '1rem' }}>
          {/* 検索入力フィールド */}
          <TextControl
            label={__('記事を検索', 'my-customtheme')}
            value={searchTerm}
            onChange={onSearchTextChange}
            // プレイスホルダーテキストを追加して、ユーザーに入力すべき内容を示唆
            placeholder={__('タイトルまたは本文を入力', 'my-customtheme')}
            style={{ marginBottom: '0.5rem' }}
          />
          {/* カテゴリー選択ボタン群 */}
          <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', marginBottom: '1rem' }}>
            <Button
              isPrimary={attributes.selectedCategoryId === 0}
              onClick={() => onSelectCategory(0)}
            >
              {__('全てのカテゴリー', 'my-customtheme')}
            </Button>
            {categories?.map((category) => (
              <Button
                key={category.id}
                isPrimary={attributes.selectedCategoryId === category.id}
                onClick={() => onSelectCategory(category.id)}
              >
                {category.name}
              </Button>
            ))}
          </div>
          {/* 投稿リスト */}
          <div style={{ maxHeight: '200px', overflowY: 'auto', border: '1px solid #ccc', padding: '0.5rem' }}>
            {posts?.map((post) => (
              <div
                key={post.id}
                style={{
                  padding: '0.5rem',
                  cursor: 'pointer',
                  backgroundColor: attributes.selectedPostId === post.id ? '#e0e0e0' : 'transparent',
                }}
                onClick={() => onSelectPost(post.id)}
              >
                {post.title.rendered}
              </div>
            ))}
            {(!posts || posts.length === 0) && (
              <p style={{color: '#888', fontStyle: 'italic'}}>{__('該当する投稿がありません', 'my-customtheme')}</p>
            )}
          </div>
        </div>
        {/* 選択された投稿の表示 */}
        {selectedPost && (
          <p style={{
            padding: '0rem 1rem 0rem 2rem',
            position: 'relative',
            margin: 0,
          }}>
            <span style={{
              content: '"\u25B6"',
              color: 'skyblue',
              position: 'absolute',
              top: '50%',
              left: 0,
              transform: 'translateY(-50%)'
            }}>
              ▶
            </span>
            {selectedPost.title.rendered}
          </p>
        )}
        {!selectedPost && (
          <p style={{color: '#888', fontStyle: 'italic'}}>{__('投稿が選択されていません', 'my-customtheme')}</p>
        )}
      </div>
    );
  },

  // フロントエンド表示はPHPで処理するため、save関数は不要
  save: () => null,
});

functions.php

// カスタムブロックの登録
function my_theme_custom_block_init() {
    // sample04 ブロックの登録
    register_block_type('customtheme/sample04', array(
      'editor_script' => 'sample04-block-script',
      'render_callback' => 'render_sample04_block',
      'attributes' => array(
          'selectedPostId' => array(
              'type' => 'number',
              'default' => 0,
          ),
      ),
  ));

    // ブロックスクリプトの登録
    $asset_file_sample04 = include(get_template_directory() . '/build/sample04.asset.php');
    wp_register_script(
        'sample04-block-script',
        get_template_directory_uri() . '/build/sample04.js',
        $asset_file_sample04['dependencies'],
        $asset_file_sample04['version']
    );
}
add_action('init', 'my_theme_custom_block_init');

// sample04ブロックのレンダリングコールバック関数
function render_sample04_block($attributes) {
  $post_id = $attributes['selectedPostId'];
  if (!$post_id) {
      return '<p>' . esc_html__('No post selected', 'my-customtheme') . '</p>';
  }

  $post = get_post($post_id);
  if (!$post) {
      return '<p>' . esc_html__('Selected post not found', 'my-custometheme') . '</p>';
  }

  $post_title = esc_html($post->post_title);
  $post_url = esc_url(get_permalink($post));

  return sprintf(
      '<p><a href="%s">%s</a></p>',
      $post_url,
      $post_title
  );
}

webpack.config.js

const defaultConfig = require('@wordpress/scripts/config/webpack.config');
const path = require('path');

module.exports = {
    ...defaultConfig,

    // エントリーポイントの設定
    entry: {
        sample04: path.resolve(__dirname, 'wp-content/themes/customtheme/src/blocks/sample04selectpost2/index.tsx'),
    },

    // 出力の設定
    output: {
        path: path.resolve(__dirname, 'wp-content/themes/customtheme/build'),
        filename: '[name].js', // [name] には entry で指定したキーが入る
    },
};

package.json

{
  "name": "1007_coconala",
  "version": "1.0.0",
  "main": "build/index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "build": "wp-scripts build",
    "start": "wp-scripts start"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "description": "",
  "dependencies": {
    "@types/wordpress__core-data": "^2.4.5",
    "@wordpress/block-editor": "^14.4.0",
    "@wordpress/blocks": "^13.9.0",
    "@wordpress/components": "^28.9.0",
    "@wordpress/core-data": "^7.9.0",
    "@wordpress/data": "^10.9.0",
    "@wordpress/i18n": "^5.9.0",
    "@wordpress/scripts": "^30.1.0",
    "react": "^18.3.1",
    "react-dom": "^18.3.1"
  },
  "devDependencies": {
    "@types/wordpress__block-editor": "^11.5.15",
    "@types/wordpress__blocks": "^12.5.14",
    "@types/wordpress__components": "^23.0.12"
  }
}
Post Selector Block Search Categories Category 1 Category 2 Post List Post Title 1 Post Title 2 Post Title 3 ▶ Selected Post Title Filter Posts Select Post