複数の場所でのフォームの再利用
Nette には、同じフォームを複数の場所でコードを重複させずに再利用する方法がいくつかあります。この記事では、避けるべきものも含めてさまざまな解を扱います。
フォームのファクトリ
コンポーネントを複数の場所で再利用するための基本的なやり方は、そのコンポーネントを作るメソッドやクラスを用意することです。そのメソッドをアプリケーションのさまざまな場所から呼びます。こうしたメソッドやクラスをファクトリと呼びます。これを factory method デザインパターンと混同しないでください。あちらはファクトリの特定の使い方を述べたもので、この話題とは直接の関係がありません。
例として、編集フォームを組み立てるファクトリを作ってみましょう。
use Nette\Application\UI\Form;
class FormFactory
{
public function createEditForm(): Form
{
$form = new Form;
$form->addText('title', 'タイトル:');
// ここにほかのフォーム項目を足します
$form->addSubmit('send', '保存');
return $form;
}
}
これでこのファクトリを、プレゼンターやコンポーネントなどアプリケーションのさまざまな場所で使えます。依存関係として要求することでそうします。まず設定ファイルにクラスを登録します。
services:
- FormFactory
そしてプレゼンターで使います。
class MyPresenter extends Nette\Application\UI\Presenter
{
public function __construct(
private FormFactory $formFactory,
) {
}
protected function createComponentEditForm(): Form
{
$form = $this->formFactory->createEditForm();
$form->onSuccess[] = function () {
// 送信されたデータの処理
};
return $form;
}
}
アプリケーションの必要に応じて、ほかの種類のフォームを作るメソッドをファクトリに足していけます。当然ながら、要素のない基本のフォームを作るメソッドを足して、ほかのメソッドからそれを使うこともできます。
class FormFactory
{
public function createForm(): Form
{
$form = new Form;
return $form;
}
public function createEditForm(): Form
{
$form = $this->createForm();
$form->addText('title', 'タイトル:');
// ここにほかのフォーム項目を足します
$form->addSubmit('send', '保存');
return $form;
}
}
createForm() メソッドはまだあまり役に立ちませんが、それはすぐに変わります。
ファクトリの依存関係
やがてフォームを多言語対応にする必要が出てくるかもしれません。つまり、すべてのフォームにトランスレーターを設定するということです。そのためには
FormFactory クラスを書き換え、コンストラクタで Translator
オブジェクトを依存関係として受け取り、作ったフォームに渡すようにします。
use Nette\Localization\Translator;
class FormFactory
{
public function __construct(
private Translator $translator,
) {
}
public function createForm(): Form
{
$form = new Form;
$form->setTranslator($this->translator);
return $form;
}
// ...
}
createForm()
メソッドは個別のフォームを作るほかのメソッドからも呼ばれるので、ここでトランスレーターを設定すれば十分です。これで完了です。プレゼンターやコンポーネントのコードを直す必要はまったくなく、素晴らしいことです。
ファクトリのクラスを増やす
別の方法として、アプリケーションで使うフォームごとに独立したファクトリのクラスを作ることもできます。こうするとコードが読みやすくなり、フォームの管理も簡単になります。もとの
FormFactory
には(翻訳のサポートなど)基本的な設定を持つ素のフォームだけを作らせ、編集フォーム専用の新しいファクトリ
EditFormFactory を作りましょう。
class FormFactory
{
public function __construct(
private Translator $translator,
) {
}
public function create(): Form
{
$form = new Form;
$form->setTranslator($this->translator);
return $form;
}
}
// ✅ コンポジションを使う
class EditFormFactory
{
public function __construct(
private FormFactory $formFactory,
) {
}
public function create(): Form
{
$form = $this->formFactory->create();
// ここにほかのフォーム項目を足します
$form->addSubmit('send', '保存');
return $form;
}
}
大事なのは、FormFactory と EditFormFactory クラスの関係がオブジェクトの継承ではなくコンポジションで実現されていることです。
// ⛔ だめです! ここに継承は要りません
class EditFormFactory extends FormFactory
{
public function create(): Form
{
$form = parent::create();
$form->addText('title', 'タイトル:');
// ここにほかのフォーム項目を足します
$form->addSubmit('send', '保存');
return $form;
}
}
ここで継承を使うのはまったく逆効果です。すぐに問題にぶつかります。たとえば
create() メソッドにパラメータを足したくなったら、シグネチャが親と違うので PHP
がエラーを報告します。あるいは EditFormFactory
クラスにコンストラクタで依存関係を渡す場合。これはコンストラクタ地獄として知られる状態を招きます。
一般に、継承よりコンポジションを優先するほうが良いのです。
フォームの処理
送信が成功したときに呼ばれるフォームのハンドラも、ファクトリのクラスの一部にできます。ハンドラは送られたデータをモデル層に渡して処理させます。処理中のエラーはフォームに戻されます。次の例では、モデルを
Facade クラスが表しています。
class EditFormFactory
{
public function __construct(
private FormFactory $formFactory,
private Facade $facade,
) {
}
public function create(): Form
{
$form = $this->formFactory->create();
$form->addText('title', 'タイトル:');
// ここにほかのフォーム項目を足します
$form->addSubmit('send', '保存');
$form->onSuccess[] = $this->processForm(...);
return $form;
}
private function processForm(Form $form, array $data): void
{
try {
// 送信されたデータの処理
$this->facade->process($data);
} catch (AnyModelException $e) {
$form->addError('...');
}
}
}
ただしリダイレクトはプレゼンター自身に任せましょう。プレゼンターは onSuccess
イベントにもうひとつハンドラを足し、そこでリダイレクトを行います。おかげで同じフォームをさまざまなプレゼンターで使え、成功時にはそれぞれ別の場所へリダイレクトできます。
class MyPresenter extends Nette\Application\UI\Presenter
{
public function __construct(
private EditFormFactory $formFactory,
) {
}
protected function createComponentEditForm(): Form
{
$form = $this->formFactory->create();
$form->onSuccess[] = function () {
$this->flashMessage('レコードを保存しました');
$this->redirect('Homepage:');
};
return $form;
}
}
この解は、フォームやその要素で addError() が呼ばれると、以降の onSuccess
ハンドラが呼ばれないというフォームの性質を利用しています。
Form クラスの継承
組み立てたフォームは Form
クラスの子孫であるべきではありません。言い換えると、次のやり方は避けてください。
// ⛔ だめです! ここに継承は要りません
class EditForm extends Form
{
public function __construct(Translator $translator)
{
parent::__construct();
$this->addText('title', 'タイトル:');
// ここにほかのフォーム項目を足します
$this->addSubmit('send', '保存');
$this->setTranslator($translator);
}
}
コンストラクタの中でフォームを組み立てる代わりに、ファクトリを使ってください。
大事なのは、Form クラスが第一にフォームを組み立てるための道具、つまり form
builder
であると認識することです。組み立てられたフォームは、その成果物と見なせます。しかし成果物は
builder の一種ではありません。両者のあいだに、継承の土台となる is a
の関係はないのです。
フォームのコンポーネント
まったく別の方法として、フォームを包むコンポーネントを作ることもできます。これは新しい可能性を開きます。コンポーネントは自分のテンプレートを持つので、たとえばフォームを特定の形で描けます。あるいはシグナルを使って AJAX で通信し、候補の表示などのために情報を動的にフォームに読み込むこともできます。
use Nette\Application\UI\Form;
class EditControl extends Nette\Application\UI\Control
{
public array $onSave = [];
public function __construct(
private Facade $facade,
) {
}
protected function createComponentForm(): Form
{
$form = new Form;
$form->addText('title', 'タイトル:');
// ここにほかのフォーム項目を足します
$form->addSubmit('send', '保存');
$form->onSuccess[] = $this->processForm(...);
return $form;
}
private function processForm(Form $form, array $data): void
{
try {
// 送信されたデータの処理
$this->facade->process($data);
} catch (AnyModelException $e) {
$form->addError('...');
return;
}
// イベントの発火
$this->onSave($this, $data);
}
}
次に、このコンポーネントを作るファクトリを用意します。インターフェースを定義するだけで十分です。
interface EditControlFactory
{
function create(): EditControl;
}
そして設定ファイルに足します。
services:
- EditControlFactory
これでファクトリを要求して、プレゼンターで使えます。
class MyPresenter extends Nette\Application\UI\Presenter
{
public function __construct(
private EditControlFactory $controlFactory,
) {
}
protected function createComponentEditForm(): EditControl
{
$control = $this->controlFactory->create();
$control->onSave[] = function (EditControl $control, $data) {
$this->redirect('this');
// あるいは編集結果へリダイレクトします。たとえば:
// $this->redirect('detail', ['id' => $data->id]);
};
return $control;
}
}