Magentoのエクステンションを作ってみよう〜中級編3〜
前回はMagentoの管理画面上にメニューを新しく追加し、独自テーブルに登録してあるデータを一覧表示する機能を作成しました。今回は以下のものを管理画面に作成します。
- データを新規登録・編集する画面
- データを記録する処理
- データを削除する処理
新規登録・編集画面に必要なもの
新規登録・編集画面の作成に最低限必要なものは以下のとおりです。
- 画面の定義
- Controllerクラス
- 画面上の部品を担当するクラス
「画面の定義」は、前回の一覧表示でも作成したものと似ています。UI Componentをベースにした定義を作成します。「Controllerクラス」は、Magento2の場合は処理ごとに作成する決まりなので、表示と保存、削除の3つは最低必要になります。最後の「画面の部品を担当するクラス」は後で取り上げますが、編集画面上のボタンをクリックした際の挙動を定義するために使います。
新規登録・編集画面の定義をしよう
今回のケースでは新規登録画面と編集画面をおなじ画面定義で行います。もちろん新規登録画面と編集画面の構成を分けることもできますが、入力項目などに違いがないのであれば同じものを使っても構いません。
XMLを記述しよう
最初に以下の場所にUI Componentを定義するXMLを作成しましょう。
<エクステンションディレクトリ>/view/adminhtml/ui_component/my_news_edit.xml
ファイルの内容は以下のとおりです。
<?xml version="1.0" encoding="UTF-8"?> <form xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Ui:etc/ui_configuration.xsd"> <argument name="data" xsi:type="array"> <item name="js_config" xsi:type="array"> <item name="provider" xsi:type="string">my_news_edit.my_news_edit_data_source</item> </item> <item name="label" xsi:type="string" translate="true">General Information</item> <item name="template" xsi:type="string">templates/form/collapsible</item> </argument> <settings> <buttons> <button name="save" class="My\News\Block\Adminhtml\News\Edit\Save"/> <button name="delete" class="My\News\Block\Adminhtml\News\Edit\Delete"/> <button name="back" class="My\News\Block\Adminhtml\News\Edit\Back"/> </buttons> <namespace>my_news_edit</namespace> <dataScope>data</dataScope> <deps> <dep>my_news_edit.my_news_edit_data_source</dep> </deps> </settings> <dataSource name="my_news_edit_data_source"> <argument name="data" xsi:type="array"> <item name="js_config" xsi:type="array"> <item name="component" xsi:type="string">Magento_Ui/js/form/provider</item> </item> </argument> <settings> <submitUrl path="mynews/index/save"/> </settings> <dataProvider class="My\News\Model\News\DataProvider" name="my_news_edit_data_source"> <settings> <requestFieldName>news_id</requestFieldName> <primaryFieldName>news_id</primaryFieldName> </settings> </dataProvider> </dataSource> <fieldset name="general"> <settings> <label/> </settings> <field name="news_id" formElement="input"> <argument name="data" xsi:type="array"> <item name="config" xsi:type="array"> <item name="source" xsi:type="string">news</item> </item> </argument> <settings> <dataType>text</dataType> <visible>false</visible> <dataScope>news_id</dataScope> </settings> </field> <field name="title" sortOrder="20" formElement="input"> <argument name="data" xsi:type="array"> <item name="config" xsi:type="array"> <item name="source" xsi:type="string">news</item> </item> </argument> <settings> <validation> <rule name="required-entry" xsi:type="boolean">true</rule> </validation> <dataType>text</dataType> <label translate="true">Block Title</label> <dataScope>title</dataScope> </settings> </field> <field name="content" formElement="wysiwyg"> <argument name="data" xsi:type="array"> <item name="config" xsi:type="array"> <item name="source" xsi:type="string">news</item> </item> </argument> <settings> <additionalClasses> <class name="admin__field-wide">true</class> </additionalClasses> <validation> <rule name="required-entry" xsi:type="boolean">true</rule> </validation> <label/> <dataScope>content</dataScope> </settings> <formElements> <wysiwyg> <settings> <wysiwyg>true</wysiwyg> </settings> </wysiwyg> </formElements> </field> </fieldset> </form>
次に、新規作成と編集画面のレイアウトXMLを定義します。以下の2つのXMLファイルを作成します。
<エクステンションディレクトリ>/view/adminhtml/layout/mynews_index_newaction.xml
<エクステンションディレクトリ>/view/adminhtml/layout/mynews_index_edit.xml
順序は逆になりますが、先にmynews_index_edit.xmlから作成します。
<?xml version="1.0"?> <page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd"> <update handle="styles"/> <update handle="editor"/> <body> <referenceContainer name="content"> <uiComponent name="my_news_edit"/> </referenceContainer> </body> </page>
mynews_index_edit.xmlでは先程定義したuiComponentを参照する定義を記述します。これで編集画面の定義は完成です。
次にmynews_index_newaction.xmlの内容を記述します。
<?xml version="1.0"?> <page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd"> <update handle="mynews_index_edit"/> <body/> </page>
mynews_index_newaction.xmlは単純に編集画面の定義を流用するだけの定義になっています。updateタグを使うと、他のUI Componentやレイアウト定義を再利用できるので、同じ定義を何度も作成しなくて済みます。
ボタンを定義しよう
my_news_edit.xmlの定義の中に、以下の3行があります。
<button name="save" class="My\News\Block\Adminhtml\News\Edit\Save"/> <button name="delete" class="My\News\Block\Adminhtml\News\Edit\Delete"/> <button name="back" class="My\News\Block\Adminhtml\News\Edit\Back"/>
この3行は、編集画面で使用する以下のボタンを担当するクラス名を定義しています。
- 保存
- 削除
- 一覧に戻る
ボタンを個別のクラスとして実装することで、他の処理を実装したくなった場合でも簡単に追加ができます。また、それぞれの処理でなにか特別な処理を行わせたい場合でも、影響範囲を局所化することができます。
ボタンクラスを格納するディレクトリの作成
ボタンクラスを作成する前に、ディレクトリを作成しましょう。my_news_edit.xmlで定義したクラス名とディレクトリは揃えておく必要がありますので、以下の場所に作成します。
<エクステンションディレクトリ>/Block/Adminhtml/News/Edit
各ボタンの親クラスの定義
各ボタンを定義する前に、同じ処理を重複して作成することがないように、共通する親クラスを定義しておきます。先程作成したディレクトリに、AbstractButton.phpを作成し、以下の内容を記述しておきます。
<?php namespace My\News\Block\Adminhtml\News\Edit; use Magento\Backend\Block\Widget\Context; use Magento\Cms\Api\BlockRepositoryInterface; use Magento\Framework\Exception\NoSuchEntityException; /** * Class AbstractButton */ class AbstractButton { /** * @var Context */ protected $context; /** * @var BlockRepositoryInterface */ protected $blockRepository; /** * @param Context $context * @param BlockRepositoryInterface $blockRepository */ public function __construct( Context $context, BlockRepositoryInterface $blockRepository ) { $this->context = $context; $this->blockRepository = $blockRepository; } /** * Return News ID * * @return int|null */ public function getNewsId() { try { return $this->blockRepository->getById( $this->context->getRequest()->getParam('news_id') )->getId(); } catch (NoSuchEntityException $e) { } return null; } /** * Generate url by route and parameters * * @param string $route * @param array $params * @return string */ public function getUrl($route = '', $params = []) { return $this->context->getUrlBuilder()->getUrl($route, $params); } }
ボタンクラスの定義
親クラスが定義できたら、各ボタンのクラスを定義していきます。my_news_edit.xmlで定義したクラス名と同じファイル名でPHPファイルを作成していきましょう。最初はSave.phpです。
<?php namespace My\News\Block\Adminhtml\News\Edit; use \Magento\Framework\View\Element\UiComponent\Control\ButtonProviderInterface; /** * Class Save */ class Save extends AbstractButton implements ButtonProviderInterface { /** * @return array */ public function getButtonData() { return [ 'label' => __('Save Block'), 'class' => 'save primary', 'data_attribute' => [ 'mage-init' => ['button' => ['event' => 'save']], 'form-role' => 'save', ], 'sort_order' => 90, ]; } }
次にDelete.phpです。
<?php namespace My\News\Block\Adminhtml\News\Edit; use \Magento\Framework\View\Element\UiComponent\Control\ButtonProviderInterface; /** * Class Delete */ class Delete extends AbstractButton implements ButtonProviderInterface { /** * @return array */ public function getButtonData() { $data = []; if ($this->getNewsId()) { $data = [ 'label' => __('Delete Block'), 'class' => 'delete', 'on_click' => 'deleteConfirm(\'' . __( 'Are you sure you want to do this?' ) . '\', \'' . $this->getDeleteUrl() . '\')', 'sort_order' => 20, ]; } return $data; } /** * @return string */ public function getDeleteUrl() { return $this->getUrl('*/*/delete', ['news_id' => $this->getNewsId()]); } }
最後はBack.phpです。
<?php namespace My\News\Block\Adminhtml\News\Edit; use \Magento\Framework\View\Element\UiComponent\Control\ButtonProviderInterface; /** * Class Back */ class Back extends AbstractButton implements ButtonProviderInterface { /** * @return array */ public function getButtonData() { return [ 'label' => __('Back'), 'on_click' => sprintf("location.href = '%s';", $this->getBackUrl()), 'class' => 'back', 'sort_order' => 10 ]; } /** * Get URL for back (reset) button * * @return string */ public function getBackUrl() { return $this->getUrl('*/*/'); } }
これで画面の定義と操作用のボタンクラス定義は終わりです。
Controllerクラスの作成
仕上げはControllerクラスを定義します。今回の場合は、
- 新規作成
- 編集
- 保存
- 削除
- 一括削除
の5つのControllerを作成します。
Controllerを作成する場所
Controllerクラスは、以下のディレクトリに作成します。前回Index.phpを作成したのと同じ場所です。
<エクステンションディレクトリ>/Controller/Adminhtml/Index/
新規作成用のControllerを定義する
最初は新規作成用のControllerです。NewAction.phpという名前でファイルを作成し、以下の内容を記述します。
<?php namespace My\News\Controller\Adminhtml\Index; class NewAction extends \Magento\Backend\App\Action { /** * @var \Magento\Backend\Model\View\Result\ForwardFactory */ protected $resultForwardFactory; /** * @param \Magento\Backend\App\Action\Context $context * @param \Magento\Backend\Model\View\Result\ForwardFactory $resultForwardFactory */ public function __construct( \Magento\Backend\App\Action\Context $context, \Magento\Backend\Model\View\Result\ForwardFactory $resultForwardFactory ) { $this->resultForwardFactory = $resultForwardFactory; parent::__construct($context); } /** * Create new News block * * @return \Magento\Framework\Controller\ResultInterface */ public function execute() { /** @var \Magento\Framework\Controller\Result\Forward $resultForward */ $resultForward = $this->resultForwardFactory->create(); return $resultForward->forward('edit'); } /** * Check Permission. * * @return bool */ protected function _isAllowed() { return $this->_authorization->isAllowed('My_News::manage'); } }
新規作成画面のControllerは、単純に処理を編集画面に転送するだけとなっています。
return $resultForward->forward('edit');
と書いているところで転送先の画面を指定しています。この場合は編集画面です。
クラス名がNewActionである理由
前回作成したmy_news_grid.xmlの15行目辺りに実は書いてありますが、新規作成用のControllerクラス名は「NewAction」です。これは「New」がPHPでは予約語であるため、クラス名に使用できないからです。他にも使えないものについては、PHPマニュアルに記載されています。ほかのクラスを作成する場合も同じですが、注意するようにしてください。うっかり使ってしまうとエラーになります。
編集用のControllerを定義する
次は編集用です。こちらはEdit.phpという名前でファイルを作成し、以下の内容を記述します。
<?php namespace My\News\Controller\Adminhtml\Index; use \My\News\Model\News; use \My\News\Api\NewsRepositoryInterface; use \Magento\Framework\Registry; use \Magento\Framework\Exception\NoSuchEntityException; class Edit extends \Magento\Backend\App\Action { /** * @var \Magento\Framework\View\Result\PageFactory */ protected $resultPageFactory; /** * @var NewsRepositoryInterface */ private $newsRepository; /** * @var Registry */ private $registry; /** * Edit constructor. * @param \Magento\Backend\App\Action\Context $context * @param \Magento\Framework\View\Result\PageFactory $resultPageFactory * @param \Magento\Framework\Registry $coreRegistry * @param NewsRepositoryInterface $newsRepository */ public function __construct( \Magento\Backend\App\Action\Context $context, \Magento\Framework\View\Result\PageFactory $resultPageFactory, \Magento\Framework\Registry $coreRegistry, NewsRepositoryInterface $newsRepository ) { $this->resultPageFactory = $resultPageFactory; $this->registry = $coreRegistry; $this->newsRepository = $newsRepository; parent::__construct($context); } /** * Edit News block * * @return \Magento\Framework\Controller\ResultInterface * @SuppressWarnings(PHPMD.NPathComplexity) */ public function execute() { $id = $this->getRequest()->getParam('news_id'); if ($id) { try { /** @var News $model */ $model = $this->newsRepository->getById($id); } catch (NoSuchEntityException $e) { $this->messageManager->addErrorMessage($e->getMessage()); /** @var \Magento\Backend\Model\View\Result\Redirect $resultRedirect */ $resultRedirect = $this->resultRedirectFactory->create(); return $resultRedirect->setPath('*/*/'); } } $this->registry->register('news_block', $model); /** @var \Magento\Backend\Model\View\Result\Page $resultPage */ $resultPage = $this->resultPageFactory->create(); $resultPage->setActiveMenu('Magento_Cms::cms_block') ->addBreadcrumb(__('News'), __('News')) ->addBreadcrumb(__('Ness'), __('News')) ->addBreadcrumb( $id ? __('Edit Block') : __('New Entry'), $id ? __('Edit Block') : __('New Entry') ); $resultPage->getConfig()->getTitle()->prepend(__('News')); $resultPage->getConfig()->getTitle()->prepend($model->getId() ? $model->getTitle() : __('New Entry')); return $resultPage; } /** * Check Permission. * * @return bool */ protected function _isAllowed() { return $this->_authorization->isAllowed('My_News::manage'); } }
編集用のControllerでは、一覧画面から渡された記事IDがあるかないかで処理を分岐しています。また、記事IDが渡された場合に、該当データがない場合はエラーとなるようにしています。
保存用のControllerを定義する
続いて保存用のControllerです。Save.phpという名前で作成します。
<?php namespace My\News\Controller\Adminhtml\Index; use \My\News\Model\NewsFactory; use \My\News\Model\News; use \My\News\Api\NewsRepositoryInterface; use \Magento\Framework\Registry; use \Magento\Framework\Exception\LocalizedException; use \Magento\Framework\App\Request\DataPersistorInterface; class Save extends \Magento\Backend\App\Action { /** * @var \Magento\Backend\Model\View\Result\RedirectFactory */ protected $resultRedirectFactory; /** * @var NewsFactory */ private $newsFactory; /** * @var Registry */ private $registry; /** * @var NewsRepositoryInterface */ private $newsRepository; /** * @var DataPersistorInterface */ private $dataPersistor; /** * Edit constructor. * @param \Magento\Backend\App\Action\Context $context * @param \Magento\Backend\Model\View\Result\RedirectFactory $resultRedirectFactory * @param \Magento\Framework\Registry $coreRegistry * @param NewsFactory $newsFactory * @param NewsRepositoryInterface $newsRepository * @param DataPersistorInterface $dataPersistor */ public function __construct( \Magento\Backend\App\Action\Context $context, \Magento\Backend\Model\View\Result\RedirectFactory $resultRedirectFactory, \Magento\Framework\Registry $coreRegistry, NewsFactory $newsFactory, NewsRepositoryInterface $newsRepository, DataPersistorInterface $dataPersistor ) { $this->resultRedirectFactory = $resultRedirectFactory; $this->registry = $coreRegistry; $this->newsFactory = $newsFactory; $this->newsRepository = $newsRepository; $this->dataPersistor = $dataPersistor; parent::__construct($context); } /** * Edit News * * @return \Magento\Framework\Controller\ResultInterface * @SuppressWarnings(PHPMD.NPathComplexity) */ public function execute() { $resultRedirect = $this->resultRedirectFactory->create(); $id = $this->getRequest()->getParam('news_id'); /** @var News $model */ $model = $this->newsFactory->create(); if ($id) { try { $model = $this->newsRepository->getById($id); } catch (LocalizedException $e) { $this->messageManager->addErrorMessage($e->getMessage()); return $resultRedirect->setPath('*/*/'); } } $data = $this->getRequest()->getPostValue(); try { if (empty($data['news_id'])) { $data['news_id'] = null; } $model->setData($data); $this->newsRepository->save($model); $this->messageManager->addSuccessMessage(__('You saved the news.')); $this->dataPersistor->clear('news_block'); } catch (LocalizedException $e) { $this->messageManager->addErrorMessage($e->getMessage()); } catch (\Exception $e) { $this->messageManager->addExceptionMessage($e, __('Something went wrong while saving the news.')); } return $resultRedirect->setPath('*/*/'); } /** * Check Permission. * * @return bool */ protected function _isAllowed() { return $this->_authorization->isAllowed('My_News::manage'); } }
保存処理のポイントは、以下の2つです。
- 記事IDがあるかないかで、新規作成か編集かを区別する
- 新規作成時は、記事IDに「null」を明示的にセットしておく
特に新規作成時に記事IDに空文字や0をセットしてしまうと、期待通りの動きをしなくなってしまうので、注意が必要です。
削除用のControllerを定義する
今度は削除用のControllerです。Delete.phpという名前で作成します。
<?php namespace My\News\Controller\Adminhtml\Index; use \My\News\Model\News; use \My\News\Api\NewsRepositoryInterface; use \Magento\Framework\Registry; use \Magento\Framework\Exception\LocalizedException; use \Magento\Framework\App\Request\DataPersistorInterface; class Delete extends \Magento\Backend\App\Action { /** * @var \Magento\Backend\Model\View\Result\RedirectFactory */ protected $resultRedirectFactory; /** * @var Registry */ private $registry; /** * @var NewsRepositoryInterface */ private $newsRepository; /** * @var DataPersistorInterface */ private $dataPersistor; /** * Edit constructor. * @param \Magento\Backend\App\Action\Context $context * @param \Magento\Backend\Model\View\Result\RedirectFactory $resultRedirectFactory * @param \Magento\Framework\Registry $coreRegistry * @param NewsRepositoryInterface $newsRepository * @param DataPersistorInterface $dataPersistor */ public function __construct( \Magento\Backend\App\Action\Context $context, \Magento\Backend\Model\View\Result\RedirectFactory $resultRedirectFactory, \Magento\Framework\Registry $coreRegistry, NewsRepositoryInterface $newsRepository, DataPersistorInterface $dataPersistor ) { $this->resultRedirectFactory = $resultRedirectFactory; $this->registry = $coreRegistry; $this->newsRepository = $newsRepository; $this->dataPersistor = $dataPersistor; parent::__construct($context); } /** * Delete News * * @return \Magento\Framework\Controller\ResultInterface * @SuppressWarnings(PHPMD.NPathComplexity) */ public function execute() { $resultRedirect = $this->resultRedirectFactory->create(); $id = $this->getRequest()->getParam('news_id'); if ($id) { try { /** @var News $model */ $model = $this->newsRepository->getById($id); $this->newsRepository->delete($model); $this->messageManager->addSuccessMessage(__('You deleted the news.')); return $resultRedirect->setPath('*/*/'); } catch (LocalizedException $e) { $this->messageManager->addErrorMessage(__('This news no longer exists.')); return $resultRedirect->setPath('*/*/'); } } $this->messageManager->addErrorMessage(__('We can\'t find a news to delete.')); return $resultRedirect->setPath('*/*/'); } /** * Check Permission. * * @return bool */ protected function _isAllowed() { return $this->_authorization->isAllowed('My_News::manage'); } }
削除処理も編集と同様に、渡された記事IDを元にしたデータの存在チェックを行ってから削除を行います。処理のほとんどはRepositoryやModelが行ってくれますので、Controllerでは値チェックや結果メッセージの振り分けだけを行っています。
一括削除処理用Controllerの作成
最後に一括削除処理を作成します。この処理は一覧画面から複数件のデータを一括で削除するために使います。前回作成したmy_news_grid.xmlの88行目付近に以下の定義があると思います。
<massaction name="listing_massaction"> <argument name="data" xsi:type="array"> <item name="config" xsi:type="array"> <item name="selectProvider" xsi:type="string">my_news_grid.my_news_grid.my_news_grid_columns.ids</item> <item name="component" xsi:type="string">Magento_Ui/js/grid/tree-massactions</item> <item name="indexField" xsi:type="string">id</item> </item> </argument> <!-- Mass actions which you want to add in your grid--> <action name="delete"> <argument name="data" xsi:type="array"> <item name="config" xsi:type="array"> <item name="type" xsi:type="string">delete</item> <item name="label" xsi:type="string" translate="true">Delete</item> <item name="url" xsi:type="url" path="mynews/index/massDelete"/> <item name="confirm" xsi:type="array"> <item name="title" xsi:type="string" translate="true">Delete</item> <item name="message" xsi:type="string" translate="true">Are you sure you want to delete the selected item(s)?</item> </item> </item> </argument> </action> </massaction>
この定義では、一括操作(MagentoではMass Actionと呼びます)に関する定義を記述しています。actionタグで定義した操作に対応するControllerを実装することで、一括操作を簡単に作成できる仕組みになっているわけです。
さて、一括操作用のクラス、MassDelete.phpを作成しましょう。以下の内容を記述してください。
<?php namespace My\News\Controller\Adminhtml\Index; use \My\News\Model\ResourceModel\News\CollectionFactory; use \My\News\Api\NewsRepositoryInterface; use Magento\Backend\App\Action\Context; use Magento\Ui\Component\MassAction\Filter; use Magento\Framework\Controller\ResultFactory; use Magento\Backend\Model\View\Result\Redirect; class MassDelete extends \Magento\Backend\App\Action { /** * @var NewsRepositoryInterface */ private $newsRepository; /** * Massactions filter. * * @var Filter */ private $filter; /** * @var CollectionFactory */ private $collectionFactory; /** * @param Context $context * @param Filter $filter * @param CollectionFactory $collectionFactory */ public function __construct( Context $context, Filter $filter, CollectionFactory $collectionFactory ) { $this->filter = $filter; $this->collectionFactory = $collectionFactory; parent::__construct($context); } /** * @return Redirect */ public function execute() { $collection = $this->filter->getCollection($this->collectionFactory->create()); $recordDeleted = 0; foreach ($collection->getItems() as $model) { $this->newsRepository->delete($model); $recordDeleted++; } $this->messageManager->addSuccessMessage( __('A total of %1 record(s) have been deleted.', $recordDeleted) ); return $this->resultFactory->create(ResultFactory::TYPE_REDIRECT)->setPath('*/*/index'); } /** * Check Permission. * * @return bool */ protected function _isAllowed() { return $this->_authorization->isAllowed('My_News::manage'); } }
一括操作の場合、一覧画面からは Magento\Ui\Component\MassAction\Filter を介して削除対象のデータが渡されます。この値を、記事一覧を管理するクラスに渡してやることで、削除対象のデータが取得できます。
あとはforeach文でループさせて、削除していけばよいわけです。この応用で、ステータスを変更したり、その他の値を一括で書き換えるという処理を実装することができます。ただし、一括処理の場合、あまりに大量のデータを処理しようとするとブラウザがタイムアウトしてしまったり、意図しないデータが更新されたりすることがあります。(実際、そういうお問い合わせを受けたことが何度もあります)
まとめ
今回はMagentoの管理画面でデータの新規作成・編集・削除機能を実装しました。Magentoの管理画面は標準で用意されているコンポーネントがそれなりにありますので、HTMLを書かなくても機能を開発することができます。もし標準で用意されていない編集機能が欲しい場合は、独自に実装してみると良いでしょう。