Skip to content

Latest commit

 

History

History
758 lines (576 loc) · 24.1 KB

element-types.md

File metadata and controls

758 lines (576 loc) · 24.1 KB

Element Types

Element types define the different types of content that can be managed in Craft.

Craft comes with 7 built-in element types:

  • craft\elements\Asset
  • craft\elements\Category
  • craft\elements\Entry
  • craft\elements\GlobalSet
  • craft\elements\MatrixBlock
  • craft\elements\Tag
  • craft\elements\User

You can refer to these classes for examples. They are located in vendor/craftcms/cms/src/elements/.

If your plugin needs to provide a new content type, architecting it as an element type is usually the best way to go.

Getting Started

Element Class

Element types are defined by classes which implement craft\base\ElementInterface and craft\base\ElementTrait. The class will serve both as a way to communicate various things about your element type (with static methods), and as a model that elements of its type will be instantiated with.

As a convenience, you can extend craft\base\Element, which provides a base element type implementation.

Create an elements/ directory within your plugin’s source directory, and create a PHP class file within it, named after the class name you want to give your element type (e.g. Product.php).

Define the class within the file, and give it some public properties for any custom attributes your elements will have.

<?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;

    // ...
}

Database Table

There will be things your elements need to store about themselves that don’t fit into the columns in the elements database table. So you’ll need to create a new table to hold that info.

Create an install migration (if you don’t already have one), and add this to its safeUp() method:

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);
}

{note} If you’re adding this as an update to an existing plugin, you will need to create a new normal migration as well, and copy the same code into it.

Install the plugin now, so your database table will be created.

You will also need to add an afterSave() method to your element class, which is responsible for keeping your element table updated when elements are saved.

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);
}

Element Query Class

All element types need a corresponding element query class. Element query classes are an extension of query builders, tuned for fetching elements.

All element query classes should extend craft\elements\db\ElementQuery, which provides the base functionality.

They have three responsibilities:

  • Provide public properties and setter methods for capturing custom criteria parameters
  • Join in the custom element table and select the appropriate columns within it
  • Apply the custom criteria parameters as conditions on the query

You can refer to Craft’s own element query classes for examples. They are located in vendor/craftcms/cms/src/elements/db/.

To give your plugin an element query, create a db/ directory within your elements/ directory, and create a PHP class file within it, named after the class name you want to give your element query (e.g. 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();
    }
}

With the element query class in place, the last step is to tie it into your element type. Add the following method to your element class:

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

// ...

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

    // ...
}

Now you’re ready to start querying for elements of your type:

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

$this->query vs. $this->subQuery

Behind the scenes, craft\elements\db\ElementQuery creates two craft\db\Query instances: the main query ($this->query), and a subquery ($this->subQuery). Column selections should go in the main query, and conditions/joins should be applied to the subquery. Ultimately the subquery will become the FROM clause of the main query.

The reason for this separation is performance. It allows MySQL/PostgreSQL to figure out exactly which element rows should be fetched before it has to worry about which columns to select, etc., avoiding the need to run expensive condition operations on temporary tables.

Element Content

If your elements should get their own rows in the content table, either because they should have titles or custom fields, add a static hasContent() method to your element class:

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

Titles

If your elements should have user-defined titles, add a static hasTitles() method to your element class:

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

Note that Element Editor HUDs do not automatically show a Title field, so you will need to add it yourself:

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;
}

Custom Fields

If you want your element type to support custom fields, you will also need to create a page somewhere within the Control Panel for managing your element type’s field layout. Craft provides a template include that will output a Field Layout Designer for you:

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

Place that include within a <form> that posts to one of your plugin’s controllers. The controller can save the field layout like this:

use ns\prefix\elements\Product;

// ...

// delete the previous product field layout
Craft::$app->getFields()->deleteLayoutsByType(Product::class);

// 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);

Rather than only having one field layout for your entire element type, you can also manage multiple field layouts, if needed. For example, entry field layouts are defined for each entry type; asset field layouts are defined for each asset volume, etc.

You can set that up however you want. When saving field layouts, make sure to delete the old one by its ID rather than by your element type (craft\services\Fields::deleteLayoutById()), and store new field layouts’ IDs in the database somewhere.

As far as your element class is concerned, you will need to override the getFieldLayout() method, so elements know which field layout they should be associated with:

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

Localization

If your elements’ title and custom field values should be stored on a per-site basis, add a static isLocalized() method:

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

By default, elements will be stored in all sites. If an element should only be stored for certain sites, add a getSupportedSites() method to it.

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

The values in the array returned by getSupportedSites() can either be integers (site IDs) or an array with a siteId key and optionally an enabledbyDefault key (boolean) indicating whether the element should be enabled by default for that site.

Statuses

If your elements should have their own statuses, give your element class a static hasStatuses() method:

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

Then, if they can have any statuses besides enabled and disabled, add a static statuses() method to define them:

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

Sources

Your element type can define “sources”, which are groups of elements defined by criteria parameters.

Element type sources will be visible in the sidebar of element indexes, and within the settings of element relation fields.

To define your element type’s sources, add a protected static defineSources() method to your element class:

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',
            ]
        ],
    ];
}

When a source is selected, Craft will configure your element query with the values listed in the source’s criteria array.

Index Page

You can give your Control Panel section an index page for your element type using the following template:

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

Index Page Actions

You can define which actions your element type supports on its index page by adding a protected static defineActions() method on your element class:

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

Sort Options

You can define the sort options for your element indexes by adding a protected static defineSortOptions() method to your element class:

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

When a sort option is selected on an index, its key will be passed to the $orderBy property of your element query class (e.g. ['price' => SORT_ASC]).

Table Attributes

You can customize which columns should be available to your element indexes’ Table views by adding a protected defineTableAttributes() method to your element class:

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

{note} The first attribute you list here is a special case. It defines the header for the first column in the table view, which is the only one admins can’t remove. Its values will be the element representations, which is also not customizable.

If it’s a big list, you can also limit which columns should be visible by default for new sources by adding a protected defineDefaultTableAttributes() method to your element class:

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

For the table cells, by default Craft will output whatever the string version of the element attribute is. You can override the cell HTML by adding a protected tableAttributeHtml() method on your element class:

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);
}

Thumb View

Thumbnail views can be be enabled for your element index page on a source-by-source basis.

To enable thumbnail view for a source, add a hasThumbs key to its definition:

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

Then, add a getThumbUrl() method to your element class, which returns the URL to the current element’s thumbnail:

use craft\helpers\UrlHelper;

// ...

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

Searchable Attributes

When an element is saved, Craft’s Search service will index its “searchable attributes” as search keywords on the element. By default, the list of searchable attributes will only include the element’s title and slug, plus any custom field values.

If your element type has additional attributes you want to make searchable, add a protected static defineSearchableAttributes(0) method on your element and list them:

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

Element URLs

When an element is being saved, its getUriFormat() method will be called to find out whether the element should have its own URI in the system, and if so, what it should look like.

So if you want your elements to get their own URLs, you must implement this method and have it return a string that can be parsed with craft\web\View::renderObjectTemplate() (e.g. products/{slug}). Usually this should be a user-defined string, rather than something hard-coded.

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

Whenever an element’s URL is requested, Craft will instantiate the element and call its getRoute() method, giving the element a chance to decide how the request should be routed.

Internally, craft\base\Element::getRoute() will call a protected route() method, which is what you should override in your element class:

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

Editing Elements

Editor HUDs

To make your elements editable via Element Editor HUDs when double-clicked on within the index page or relation fields, add a getIsEditable() method to your element class, which returns whether the current user has permission to edit the element:

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

By default the element editor HUD will only include custom fields. To include a Title field and/or any element-specific attribute fields, add a getEditorHtml() method to your element class:

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;
}

Edit Page

If you want to give your element type a full-sized edit page, it’s up to you to set all of that up – the templates, the routes, and the controller actions.

The Edit Category page offers a relatively straightforward example of how it could be done.

  • URL Rules:

      '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',
    
  • Controller actions:

    • craft\controllers\actionEditCategory – renders the Edit Category page
    • craft\controllers\actionPreviewCategory – renders a category’s front-end page for a Live Preview request
    • craft\controllers\actionSaveCategory – saves a category
    • craft\controllers\actionDeleteCategory – deletes a category
    • craft\controllers\actionShareCategory – handles a Share Category request, creating a token for categories/view-shared-category and redirecting the user to it
    • craft\controllers\actionViewSharedCategory – renders a category’s front-end page for a Share Category token
  • Edit Category page template: vendor/craftcms/cms/src/templates/categories/_edit.html

Once you’ve set up an edit page for your element type, you can add a getCpEditUrl() method to your element class, which will communicate your elements’ edit page URLs within the Control Panel.

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

Relations

Relation Field

You can give your element its own relation field by creating a new field type that extends craft\fields\BaseRelationField.

That base class does most of the grunt work for you, so you can get your field up and running by implementing three simple methods:

<?php
namespace ns\prefix\fields;

use craft\fileds\BaseRelationsField;
use ns\prefix\elements\Product;

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

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

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

Reference Tags

If you want your elements to support reference tags (e.g. {product:100}), add a static refHandle() method to your element class that returns a unique handle that should be used for its reference tags.

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

To make it easier for users to copy your elements’ reference tags, you may want to add a “Copy reference tag” action to your element’s index page.

use craft\elements\actions\CopyReferenceTag;

// ...

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

Eager-Loading

If your element type has its own relation field, it is already eager-loadable through that. And if it supports custom fields, any elements that are related to your elements through relation fields will be eager-loadable.

The only case where eager-loading support is not provided for free is if your element type has any “hard-coded” relations with other elements. For example, entries have authors (User elements), but those relations are defined in an authorId column in the entries table, not a custom Users field.

If your elements have any hard-coded relations to other elements, and you want to make those elements eager-loadable, add an eagerLoadingMap() method to your element class:

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);
}

This function takes an of already-queried elements (the “source” elements), and an eager-loading handle. It is supposed to return a mapping of which source element IDs should eager-load which “target” element IDs.

If you need to override where eager-loaded elements are stored, add a setEagerLoadedElements() method to your element class as well:

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