エレメントタイプ

エレメントタイプは、Craft で管理できる様々なタイプのコンテンツを定義します。

Craft には、7つの組み込みフィールドタイプがあります。

これらのクラスの実例を参照できます。それらは vendor/craftcms/cms/src/elements/ にあります。

プラグインが新しいコンテンツタイプを提供する必要がある場合、エレメントタイプとして設計することが通常は最善の方法です。

はじめよう

エレメントクラス

エレメントタイプは、craft\base\ElementInterfacecraft\base\ElementTrait を実装するクラスによって定義されます。クラスは、(静的メソッドで)エレメントタイプについての様々なことと伝える手段としてだけでなく、その型のエレメントがインスタンス化されるモデルとしても役立ちます。

便利なものとして、基本エレメントタイプの実装を提供する craft\base\Element を拡張できます。

プラグインのソースディレクトリ内で elements/ ディレクトリを作成し、その中に提供したいエレメントタイプのクラス名にちなんで名付けられた PHP クラスファイルを作成します(例:Product.php)。

そのファイル内にクラスを定義し、エレメントに持たせたいカスタム属性のためのパブリックプロパティを与えます。

<?php
namespace ns\prefix\elements;

use craft\base\Element;

class Product extends Element
{
    /**
     * @var int Price
     */
    public $price = 0;

    /**
     * @var string Currency code
     */
    public $currency;

    // ...
}

データベーステーブル

elements データベーステーブルのカラムにフィットしない、エレメント自身について保存したいことがあるでしょう。そこで、その情報を保持するための新しいテーブルを作成する必要があります。

(まだ用意していない場合)インストールマイグレーションを作成し、 safeUp() メソッドにこれを追加してください。

if (!$this->db->tableExists('{{%products}}')) {
    // create the products table
    $this->createTable('{{%products}}', [
        'id' => $this->integer()->notNull(),
        'price' => $this->integer()->notNull(),
        'currency' => $this->char(3)->notNull(),
        'dateCreated' => $this->dateTime()->notNull(),
        'dateUpdated' => $this->dateTime()->notNull(),
        'uid' => $this->uid(),
        'PRIMARY KEY(id)',
    ]);

    // give it a FK to the elements table
    $this->addForeignKey(
        $this->db->getForeignKeyName('{{%products}}', 'id'),
        '{{%products}}', 'id', '{{%elements}}', 'id', 'CASCADE', null);
}

TIP

既存のプラグインのアップデートとしてこれを追加する場合、同様に新しい通常のマイグレーションを作成し、同じコードをその中にコピーする必要があります。

プラグインをインストールすると、データベーステーブルが作成されるでしょう。

エレメントが保存された際、エレメントテーブルをアップデートし続ける責任があるエレメントクラスに afterSave() メソッドを追加する必要もあります。afterSave() メソッドは、エレメントを保存する標準的な制御フローの一部です。

public function afterSave(bool $isNew)
{
    if ($isNew) {
        \Craft::$app->db->createCommand()
            ->insert('{{%products}}', [
                'id' => $this->id,
                'price' => $this->price,
                'currency' => $this->currency,
            ])
            ->execute();
    } else {
        \Craft::$app->db->createCommand()
            ->update('{{%products}}', [
                'price' => $this->price,
                'currency' => $this->currency,
            ], ['id' => $this->id])
            ->execute();
    }

    parent::afterSave($isNew);
}

エレメントクエリクラス

すべてのエレメントタイプは、対応するエレメントクエリクラスが必要です。エレメントクエリクラスは、エレメントを取得するためにチューニングされたクエリビルダーの拡張です。

すべてのエレメントクエリクラスは、基本機能を提供する craft\elements\db\ElementQuery を拡張する必要があります。

それらには、3つの責任があります。

  • カスタム基準パラメータをキャプチャするためのパブリックプロパティと Setter メソッドを提供します
  • カスタムエレメントテーブルに結合し、その中にある適切なカラムを選択します
  • カスタム基準パラメータをクエリの条件として適用します

実例として、Craft 独自のエレメントクラスを参照できます。それらは vendor/craftcms/cms/src/elements/db/ にあります。

プラグインにエレメントクエリを渡すために、elements/ ディレクトリ内に db/ ディレクトリを作成し、提供したいエレメントクエリにちなんで名付けられた PHP クラスファイルを作成します(例:ProductQuery.php)。

<?php
namespace ns\prefix\elements\db;

use craft\db\Query;
use craft\elements\db\ElementQuery;
use craft\helpers\Db;
use ns\prefix\elements\Product;

class ProductQuery extends ElementQuery
{
    public $price;
    public $currency;

    public function price($value)
    {
        $this->price = $value;

        return $this;
    }

    public function currency($value)
    {
        $this->currency = $value;

        return $this;
    }

    protected function beforePrepare(): bool
    {
        // join in the products table
        $this->joinElementTable('products');

        // select the price column
        $this->query->select([
            'products.price',
            'products.currency',
        ]);

        if ($this->price) {
            $this->subQuery->andWhere(Db::parseParam('products.price', $this->price));
        }

        if ($this->currency) {
            $this->subQuery->andWhere(Db::parseParam('products.currency', $this->currency));
        }

        return parent::beforePrepare();
    }
}

エレメントクエリクラスが配置されていれば、最後のステップはエレメントタイプに結びつけることです。エレメントクラスに次のメソッドを追加してください。

use craft\elements\db\ElementQueryInterface;
use ns\prefix\elements\db\ProductQuery;

// ...

class Product
{
    public static function find(): ElementQueryInterface
    {
        return new ProductQuery(static::class);
    }

    // ...
}

あなたの型のエレメント向けに照会しはじめる準備ができました。

Product::find()
    ->price(100)
    ->all();

$this->query$this->subQuery

その裏で、craft\elements\db\ElementQuery は2つの craft\db\Query インスタンスを作成します。メインクエリ($this->query)とサブクエリ($this->subQuery)です。カラムの選択はメインクエリで行い、条件 / 結合はサブクエリに適用する必要があります。最終的に、サブクエリはメインクエリの FROM 句になります。

このように分かれている理由は、パフォーマンスです。一時テーブルでコストの高い条件操作を実行する必要を避け、どのカラムを選択するかなどを気にすることなく、どのエレメント行を取得するかを MySQL / PostgreSQL が正確に把握できるようにします。

テンプレートファンクション

テンプレートでエレメントを照会できるようにする場合、新しいエレメントクエリを返す新しいテンプレートファンクションを作成できます。(詳細については、Twig の拡張 を参照してください。)

<?php
namespace ns\prefix;

use Craft;
use yii\base\Behavior;

/**
 * Adds a `craft.products()` function to the templates (like `craft.entries()`)
 */
class CraftVariableBehavior extends Behavior
{
    public function products($criteria = null): ProductQuery
    {
        $query = Product::find();
        if ($criteria) {
            Craft::configure($query, $criteria);
        }
        return $query;
    }
}

エレメントコンテンツ

エレメントが content テーブル内の独自の行を取得する必要がある場合、タイトルコンテンツフィールドを持つ必要があるため、エレメントクラスに static な hasContent() メソッドを追加します。

public static function hasContent(): bool
{
    return true;
}

タイトル

エレメントがユーザー定義のタイトルを持つ場合、エレメントクラスに static な hasTitles() メソッドを追加します。

public static function hasTitles(): bool
{
    return true;
}

エレメントエディタの HUD はタイトルフィールドを自動的に表示しないため、自身で追加する必要があることに注意してください。

public function getEditorHtml(): string
{
    $html = \Craft::$app->getView()->renderTemplateMacro('_includes/forms', 'textField', [
        [
            'label' => \Craft::t('app', 'Title'),
            'siteId' => $this->siteId,
            'id' => 'title',
            'name' => 'title',
            'value' => $this->title,
            'errors' => $this->getErrors('title'),
            'first' => true,
            'autofocus' => true,
            'required' => true
        ]
    ]);

    // ...

    $html .= parent::getEditorHtml();

    return $html;
}

カスタムフィールド

フィールドレイアウトの管理

エレメントタイプでカスタムフィールドをサポートする場合、エレメントタイプのフィールドレイアウトを管理するためにコントロールパネル内のどこかにページを作成する必要があります。Craft は Field Layout Designer を出力するテンプレートのインクルードを提供します。

{% include "_includes/fieldlayoutdesigner" with {
    fieldLayout: craft.app.fields.getLayoutByType('ns\\prefix\\elements\\Product')
} only %}

プラグインのコントローラーの1つの投稿のための <form> 内に include を配置してください。コントローラーは、次のようにフィールドレイアウトを保存できます。

use ns\prefix\elements\Product;

// ...

// assemble the new one from the post data, and save it
$fieldLayout = \Craft::$app->getFields()->assembleLayoutFromPost();
$fieldLayout->type = Product::class;
\Craft::$app->getFields()->saveLayout($fieldLayout);

エレメントタイプ全体で1つのフィールドレイアウトを持つのではなく、必要であれば複数のフィールドレイアウトを管理することもできます。例えば、エントリのフィールドレイアウトはそれぞれの入力タイプ向けに定義され、アセットのフィールドレイアウトはそれぞれのアセットボリューム向けに定義されます。

あなたがしたいように、セットすることができます。データベースのどこかに、新しいフィールドレイアウトの ID を保存することを忘れないでください。($fieldLayout->id 経由で saveLayout() を呼び出した後、フィールドレイアウトの ID にアクセスできます。)

フィールドレイアウトへのエレメントの関連付け

エレメントの getFieldLayout() メソッドは(存在する場合)現在のエレメントに関連付けられたフィールドレイアウトを返す責任があります。デフォルトでは、エレメントの $fieldLayoutId プロパティをチェックします。セットされている場合、同じ ID のフィールドレイアウトを返します。そのため、それらを保存する際、エレメントに $fieldLayoutId プロパティをセットすることを推奨します。

// ...
$product->fieldLayoutId = $productType->fieldLayoutId;
\Craft::$app->elements->saveElement($product);

$fieldLayoutId プロパティがセットされている場合、craft\services\Elements::saveElement() はデータベースの elements.fieldLayoutId カラムに保存し、ロード時に取得されたその値をエレメントに再設定します。

あるいは、getFieldLayout() メソッドを上書きし、フィールドレイアウトを fetch / return することもできます。これは(ユーザーアカウントのように)エレメントタイプが単一のフィールドレイアウトしか持たない場合、むしろ望ましいかもしれません。

public function getFieldLayout()
{
    return \Craft::$app->fields->getLayoutByType(Product::class);
}

ローカライゼーション

エレメントのタイトルやカスタムフィールドの値がサイト単位で保存されている場合、static な isLocalized() メソッドを追加してください。

public static function isLocalized(): bool
{
    return true;
}

デフォルトでは、エレメントはすべてのサイトに保存されます。特定のサイトだけにエレメントを保存する必要がある場合、getSupportedSites() メソッドを追加してください。

public function getSupportedSites(): array
{
    return [
        1,
        2,
        ['siteId' => 3, 'enabledByDefault' => false],
    ];
}

getSupportedSites() によって返される配列内の値は、整数値(サイト ID)、または、siteId キーとそのサイトでエレメントがデフィルトで使用可能であるべきかを示すオプションの enabledbyDefault キー(ブール値)の配列のいずれかにできます。

ステータス

エレメントに独自のステータスが必要な場合、エレメントクラスに static な hasStatuses() メソッドを加えます。

public static function hasStatuses(): bool
{
    return true;
}

次に、enableddisabled 以外のステータスを持つことができる場合、それらを定義するために static な statuses() メソッドを追加してください。

public static function statuses(): array
{
    return [
        'foo' => \Craft::t('plugin-handle', 'Foo'),
        'bar' => \Craft::t('plugin-handle', 'Bar'),
    ];
}

ソース

エレメントタイプは、基準パラメータで定義されたエレメントのグループである「ソース」を定義できます。

エレメントタイプのソースは、エレメントインデックスのサイドバーとエレメントの関連フィールドの設定内に表示されます。

エレメントタイプのソースを定義するために、エレメントクラスに protected static な defineSources() メソッドを追加してください。

protected static function defineSources(string $context = null): array
{
    return [
        [
            'key' => '*',
            'label' => 'All Products',
            'criteria' => []
        ],
        [
            'key' => 'cad',
            'label' => 'CAD',
            'criteria' => [
                'currency' => 'cad',
            ]
        ],
        [
            'key' => 'usd',
            'label' => 'USD',
            'criteria' => [
                'currency' => 'usd',
            ]
        ],
    ];
}

ソースが選択されると、Craft はソースの criteria 配列にリストされている値でエレメントクエリを設定します。

インデックスページ

次のテンプレートを使用して、コントロールパネルのセクションにエレメントタイプのインデックスページを加えることができます。

{% extends '_layouts/elementindex' %}
{% set title = 'Products' %}
{% set elementType = 'ns\\prefix\\elements\\Product' %}

インデックスページアクション

エレメントクラスに protected static な defineActions()メソッドを追加することで、インデックスページでエレメントタイプをサポートするアクションを定義できます。

protected static function defineActions(string $source = null): array
{
    return [
        FooAction::class,
        BarAction::class,
    ];
}

復元アクション

すべてのエレメントは、デフォルトでソフトデリートできます。しかしながら、復元可能かどうかはそれぞれのエレメントタイプによって決まります。

要素を復元可能にするには、static な defineActions() メソッドによって返される配列に craft\elements\actions\Restore アクションを追加するだけです。Craft は通常のインデックスビューから自動的にそれを隠し、ステータスオプションで「破棄済み」を選択したときだけ表示します。

ソートオプション

エレメントクラスに protected static な defineSortOptions() メソッドを追加することで、エレメントインデックス向けのソートオプションを定義できます。

protected static function defineSortOptions(): array
{
    return [
        'title' => \Craft::t('app', 'Title'),
        'price' => \Craft::t('plugin-handle', 'Price'),
    ];
}

ソートオプションがインデックスで選択されると、キーがエレメントクエリクラスの $orderBy プロパティに渡されます(例:['price' => SORT_ASC])。

テーブル属性

エレメントクラスに protected な defineTableAttributes() メソッドを追加することで、エレメントインデックスのテーブルビューで利用可能な列をカスタマイズできます。

protected static function defineTableAttributes(): array
{
    return [
        'title' => \Craft::t('app', 'Title'),
        'price' => \Craft::t('plugin-handle', 'Price'),
        'currency' => \Craft::t('plugin-handle', 'Currency'),
    ];
}

TIP

ここでリストする最初の属性は、特別なケースです。唯一管理者が削除できない、テーブルビューの最初の列のヘッダーを定義します。その値は(__toString() メソッドが返す)エレメントの文字列表現です。

大きなリストの場合、エレメントクラスに protected な defineDefaultTableAttributes() メソッドを追加することで、新しいソース向けのデフォルトで表示する列を制限することもできます。

protected static function defineDefaultTableAttributes(string $source): array
{
    return ['title', 'price', 'currency'];
}

テーブルセル向けに、Craft はデフォルトでエレメント属性の文字列の型を出力します。エレメントクラスに protected な tableAttributeHtml() メソッドを追加することで、セルの HTML を上書きできます。

protected function tableAttributeHtml(string $attribute): string
{
    switch ($attribute) {
        case 'price':
            return \Craft::$app->formatter->asCurrency($this->price, $this->currency);

        case 'currency':
            return strtoupper($this->currency);
    }

    return parent::tableAttributeHtml($attribute);
}

サムネイルビュー

サムネイルビューは、ソース単位でエレメントインデックスページ向けに有効にすることができます。

ソースのサムネイルビューを有効にするには、その定義に hasThumbs キーを追加してください。

protected static function defineSources(string $context = null): array
{
    return [
        [
            'key' => 'cad',
            'label' => 'CAD',
            'criteria' => [
                'currency' => 'cad',
            ],
            'hasThumbs' => true
        ],
        // ...
    ];
}

次に、エレメントクラスに現在のエレメントのサムネイルの URL を返す getThumbUrl() メソッドを追加してください。

use craft\helpers\UrlHelper;

// ...

public function getThumbUrl(int $size)
{
    return UrlHelper::resourceUrl("product-images/{$this->id}/{$size}");
}

検索可能な属性

エレメントが保存されると、Craft の検索サービスはそのエレメントの検索キーワードとして「検索可能な属性」をインデックスします。デフォルトでは、検索可能な属性のリストにはエレメントのタイトルとスラグ、および、カスタムフィールドの値のみが含まれます。

エレメントタイプに検索可能な属性を追加したい場合、エレメントに protected static な defineSearchableAttributes() メソッドを追加し、それらをリストに入れてください。

protected static function defineSearchableAttributes(): array
{
    return ['price'];
}

エレメント URL

エレメントが保存される際、エレメントがシステム内で独自の URI を持ち、存在する場合はどのような見た目になるかを探すために getUriFormat() メソッドが呼び出されます。

そのため、エレメントの独自の URL を得る場合、このメソッドを実装し、craft\web\View::renderObjectTemplate() で解析できる文字列(例:products/{slug})を返さなければなりません。通常、これはハードコードされたものではなく、ユーザー定義の文字列である必要があります。

public function getUriFormat()
{
    return $this->getType()->uriFormat;
}

エレメントの URL がリクエストされるたびに、Craft はエレメントをインスタンス化し、その getRoute() メソッドを呼び出し、リクエストがどのようにルーティングされるべきか、エレメントに決定するチャンスを与えます。

内部的には、craft\base\Element::getRoute() はエレメントクラスで上書きしたい protected な route() メソッドを呼び出します。

protected function route()
{
    return [
        'templates/render', [
            'template' => $this->getType()->template,
            'variables' => [
                'product' => $this,
            ]
        ]
    ];
}

エレメントの編集

エディタの HUD

インデックスページや関連フィールド内でダブルクリックした際、エレメントエディタの HUD 経由でエレメントを編集できるようにするには、エレメントクラスに現在のユーザーがエレメントを編集する権限を持っているかどうかを返す getIsEditable() メソッドを追加してください。

public function getIsEditable(): bool
{
    return \Craft::$app->user->checkPermission('edit-product:'.$this->getType()->id);
}

デフォルトでは、エレメントエディターの HUD はカスタムフィールドだけが含まれます。タイトルフィールド、および / または、エレメント固有の属性フィールドを含めるには、エレメントクラスに getEditorHtml() メソッドを追加してください。

public function getEditorHtml(): string
{
    $html = \Craft::$app->getView()->renderTemplateMacro('_includes/forms', 'textField', [
        [
            'label' => \Craft::t('app', 'Title'),
            'siteId' => $this->siteId,
            'id' => 'title',
            'name' => 'title',
            'value' => $this->title,
            'errors' => $this->getErrors('title'),
            'first' => true,
            'autofocus' => true,
            'required' => true
        ]
    ]);

    $html .= parent::getEditorHtml();

    return $html;
}

ページの編集

エレメントタイプにフルサイズの編集ページを与えたい場合、テンプレート、ルート、コントローラーアクションのすべてをセットする必要があります。

カテゴリの編集ページは、それを実行するための比較的簡単な実例を提供します。

  • URL ルール:

     'categories/<groupHandle:{handle}>/new' => 'categories/edit-category',
     'categories/<groupHandle:{handle}>/<categoryId:\d+><slug:(?:-{slug})?>' => 'categories/edit-category',
     'categories/<groupHandle:{handle}>/<categoryId:\d+><slug:(?:-{slug})?>/<siteHandle:{handle}>' => 'categories/edit-category',
     'categories/<groupHandle:{handle}>/new/<siteHandle:{handle}>' => 'categories/edit-category',
    
  • コントローラーアクション:

    • actionEditCategory() – カテゴリの編集ページをレンダリングします
    • actionPreviewCategory() – ライブプレビューリクエストのカテゴリのフロントエンドページをレンダリングします
    • actionSaveCategory() – カテゴリを保存します
    • actionDeleteCategory() – カテゴリを削除します
    • actionShareCategory() – Share Category リクエストを操作し、categories/view-shared-category のトークンを作成し、ユーザーをそこにリダイレクトします
    • actionViewSharedCategory() – Share Category トークンのカテゴリのフロントエンドページをレンダリングします
  • カテゴリの編集ページテンプレート: categories/_edit.html

エレメントの編集ページをセットアップしたら、エレメントクラスにコントロールパネル内でエレメントの編集ページ URL を伝える getCpEditUrl() メソッドを追加してください。

public function getCpEditUrl()
{
    return 'plugin-handle/products/'.$this->id;
}

リレーション

リレーションフィールド

craft\fields\BaseRelationField を拡張する新しいフィールドタイプを作成することで、エレメントに独自の関連フィールドを与えることができます。

その基本クラスは単純な作業のほとんどを行います。そのため、3つのシンプルなメソッドを実装することで、フィールドを稼働させることができます。

<?php
namespace ns\prefix\fields;

use craft\fields\BaseRelationField;
use ns\prefix\elements\Product;

class Products extends BaseRelationField
{
    public static function displayName(): string
    {
        return \Craft::t('plugin-handle', 'Products');
    }

    protected static function elementType(): string
    {
        return Product::class;
    }

    public static function defaultSelectionLabel(): string
    {
        return \Craft::t('plugin-handle', 'Add a product');
    }
}

リファレンスタグ

エレメントで参照タグ(例:{product:100})をサポートする場合、エレメントクラスに参照タグに使用されるユニークなハンドルを返す static な refHandle() メソッドを追加してください。

public static function refHandle()
{
    return 'product';
}

ユーザーがエレメントの参照タグを簡単にコピーできるようにするには、エレメントのインデックスページに「リファレンスタグのコピー」アクションを追加する必要があります。

use craft\elements\actions\CopyReferenceTag;

// ...

protected static function defineActions(string $source = null): array
{
    return [
        [
            'type' => CopyReferenceTag::class,
            'elementType' => static::class,
        ],
        // ...
    ];
}

Eager-Loading

エレメントタイプが独自の関連フィールドを持っているなら、それはすでに eager-loadable です。そして、カスタムフィールドをサポートする場合、関連フィールドでエレメントに関連づけられているすべてのエレメントは eager-loadable になります。

eager-loading サポートが自由に提供されない唯一のケースは、エレメントタイプが他のエレメントと「ハードコーディングされた」関連付けを持っている場合です。例えば、エントリは著者(ユーザーエレメント)を持ちますが、これらのリレーションはカスタムのユーザーフィールドではなく、entries テーブルの authorId カラムに定義されています。

エレメントに他のエレメントとハードコーディングされたリレーションがあり、それらのエレメントを eager-loadable にしたい場合、エレメントクラスに eagerLoadingMap() メソッドを追加してください。

use craft\db\Query;
use craft\elements\User;
use craft\helpers\ArrayHelper;

// ...

public static function eagerLoadingMap(array $sourceElements, string $handle)
{
    if ($handle === 'author') {
        // get the source element IDs
        $sourceElementIds = ArrayHelper::getColumn($sourceElements, 'id');

        $map = (new Query())
            ->select(['id as source', 'authorId as target'])
            ->from(['{{%entries}}'])
            ->where(['and', ['id' => $sourceElementIds], ['not', ['authorId' => null]]])
            ->all();

        return [
            'elementType' => User::class,
            'map' => $map
        ];
    }

    return parent::eagerLoadingMap($sourceElements, $handle);
}

このファンクションは、すでに照会されたエレメント(「ソース」エレメント)と、eager-loading のハンドルを受け入れます。どのソースエレメント ID がどの「ターゲット」エレメント ID を eager-load すべきかのマッピングを返すサポートをします。

eager-loaded エレメントが保存されている場所を上書きする必要がある場合、エレメントクラスに setEagerLoadedElements() メソッドを追加してください。

public function setEagerLoadedElements(string $handle, array $elements)
{
    if ($handle === 'author') {
        $author = $elements[0] ?? null;
        $this->setAuthor($author);
    } else {
        parent::setEagerLoadedElements($handle, $elements);
    }
}