symfonyでFormを使うときって、mergeFormやらembedFormって使っていますか?
私は、最近までは別々のFormクラスを作って、アクションクラスで使うFormクラスをtemplateにrenderしていました。はい。力業力業。よくないねぇ。
最近は、その辺を激しくリファクタリングして、前から気になっていたmergeFormやembedForm使ってみました。で、いろいろ使ってみると、これいいじゃない?というわけで、そのことをブログに書いてみることにします。
環境は、1.2.5-dev。まぁ、1.2.4でも動きます。embedFormForEachというのもあって、複数のフォームを使いたいときは、そちらを使う方がスマートのような感じがしますが、バグがあるせいか、どうもうまくいかないので、embedFormをループで回して使ってみることにしました。今回は、複数個アップロードするということで、mergeFormは扱わずに説明します。まぁ、いつか説明してもいいけど、こちらはすでにいくつもサンプルがあるので、そちらを勝手に調べてくださいな。私は、embedFormで画像のアップロードを扱うことにします。
超簡単なサンプルということで、スキーマはこんな感じでどうかしら?
propel:
posts:
_attributes: { phpName: Post }
id:
title:
type: varchar(128)
required: true
description:
type: longvarchar
required: true
created_at:
updated_at:
pictures:
_attributes: { phpName: Picture }
id:
post_id:
type: integer
required: true
foreignTable: posts
foreignReference: id
onDelete: cascade
onUpdate: cascade
filename:
type: varchar(255)
required: true
caption:
type: varchar(128)
required: true
相変わらずpropel使っています。説明はいらないと思うけども、一応。
postsというテーブルと、picturesというテーブルを用意して、picturesにはpost_idというカラムを持たせて、postsテーブルにリレーションをさせます。まぁ、そんだけ。つまり、postsとpicturesは、1-N関係なのですね。
databases.ymlなどの設定をしておいて、propel:build-allをするとformクラスやmodelクラスの雛形を作成してくれますね。モジュールも手で作るのが面倒なのと今回の説明には大事なところではないので、propel:generate-module post Postで作っておきます。post/templates/_form.phpは、現在のPostFormオブジェクトによって、作成されるので、echo $formだけで超シンプルにしておきます。つまり、以下のようにしておいてください。
getObject()->isNew()): ?>
では、まずPostフォームを用意します。propel:build-allをするとbuild-formもついでにしてくれるので、lib/form/PostForm.class.phpができていると思います。これをちょいといじってみます。
public function configure() {
unset($this['created_at'], $this['updated_at']);
$this->setWidgets(array(
'id' => new sfWidgetFormInputHidden(),
'title' => new sfWidgetFormInput(array(
'label' => 'タイトル',
)),
'description' => new sfWidgetFormTextarea(array(
'label' => '説明',
)),
));
$this->setValidators(array(
'id' => new sfValidatorPropelChoice(
array('model' => 'Post', 'column' => 'id', 'required' => false)
),
'title' => new sfValidatorString(
array('max_length' => 128, 'min_length' => 3),
array(
'max_length' => 'タイトルは128文字以内でお願いします。',
'min_length' => 'タイトルは3文字以上でお願いします。'
)
),
'description' => new sfValidatorString(
array('max_length' => 2048, 'min_length' => 3),
array(
'max_length' => '説明本文は2000文字以内でお願いします。',
'min_length' => '説明本文は3文字以上でお願いします。'
)
),
));
$this->widgetSchema->setNameFormat('post[%s]');
}
}
左の画像のようになりますね。まぁ、こんなもんでしょう。titleとdescriptionだけを入力する単純なフォームですね。バリデーションは適当に書いてみました。ごくごく初歩的なsfPropelFormの使い方だと思います。説明はいらないですね。
では、PictureFormも書いてみます。
class PictureForm extends BasePictureForm
{
public function configure()
{
unset($this['post_id']);
$captions = array('A', 'B', 'C', 'D');
$this->setWidgets(array(
'id' => new sfWidgetFormInputHidden(),
'filename' => new sfWidgetFormInputFileEditable(array(
'label' => false,
'delete_label' => 'ファイルを削除する',
'file_src' => '/uploads/' . $this->getObject()->getFilename(),
'is_image' => true,
'edit_mode' => !$this->isNew(),
'template' => '
%file%%input%%delete% %delete_label%
',
)),
'caption' => new sfWidgetFormSelect(
array(
'label' => '説明',
'choices' => array_combine($captions, $captions)
)
)
));
$this->setValidators(array(
'id' => new sfValidatorPropelChoice(array(
'model' => 'Picture', 'column' => 'id', 'required' => false
)),
'filename' => new sfValidatorFile(
array('required' => false, 'path' => sfConfig::get('sf_upload_dir')),
array(
'max_size' => 'ファイルサイズが大きすぎます。',
'mime_types' => '投稿できる画像フォーマットではありません。',
'partial' => 'ファイルアップロードに失敗しました。もう一度、投稿してください
。',
'no_tmp_dir' => 'システムエラーです。管理者にお伝えください。',
'cant_write' => 'システムエラーです。管理者にお伝えください。',
'extension' => 'システムエラーです。管理者にお伝えください。'
)),
'caption' => new sfValidatorString(array(
'max_length' => 255, 'required' => false
)),
'filename_delete' => new sfValidatorPass()
));
$this->widgetSchema->setFormFormatterName('list');
}
}
これも説明の必要はないかな。symfony1.2からsfWidgetFormInputFileEditableというwidgetが追加されて、これがファイルアップロード関係でいろいろやってくれるのですね。便利になったものです。使い方はこのソースを読んでもいいですし、jobeetのadmin-generatorの章にも書いてありますので、それを参照してください。ここでは、画像だけをアップロードしてもいいのですが、せっかくなので、画像の説明としてcaptionというカラムもpictureテーブルに持たせてみることにします。A,B,C,Dというのはいいアイデアがなかったのとサンプルなので、まぁ、適当に。
これで、二つの独立したテーブルができましたね。しかし、postの投稿フォームにpictureも一緒に投稿させたいじゃないですか。さらに、pictureが複数投稿できたら尚良さそうですよね。ということで、PostFormにPictureFormをembedすることにします。しかも、複数。ここは決め打ちで3つとします。まぁ、サンプルなので。
PostFormのバリデーションの記述の後にでも、次のコードを追記してみましょう。
$pictures = ($this->getObject()->isNew()) ? null: $this->getObject()->getPictures();
for ($i = 0; $i < 3; $i++) {
$picture = (isset($pictures) and isset($pictures[$i])) ? $pictures[$i] : null;
$pictureForm = new PictureForm($picture);
$this->embedForm('picture_' . $i, $pictureForm, '%content%');
$this->widgetSchema['picture_' . $i]->setLabel('画像');
}
少し説明しますと、Postが新規投稿であれば、何もしないですが、編集の際には、PictureFormに初期値を設定させて、それをembedFormしていきます。embedFormをする際には、picture_0, picture_1, picture_2というnameを与えて指定してみることにします。また、embedですから入れ子になりますし、ちょっと見た目がダサくなるのでデコレーションは、%content%にしておきます。ついでにlabelも指定しておきます。Formとして表示させるには、これだけ追加するだけで、左の画像のように、title, descriptionだけではなく、3つのPictureFormつまり、filename, captionが3つあるフォームができあがります。
ただ、これだけでは、このpictureがこのpostに関連づけられているかどうかがわからないので、PostFormのupdateObjectでsetPostしてあげます。
public function updateObject($values = null)
{
$object = parent::updateObject($values);
$values = $this->getValues();
foreach ($this->embeddedForms as $i => $picture) {
if ($picture->getObject()) {
if ($picture->getObject()->getFilename() == '') {
unset($this->embeddedForms[$i]);
} else {
$picture->getObject()->setPost($object);
}
}
}
}
ここは少し説明がいりますね。まず、PostのupdateObjectはそのまま親のメソッド呼んで、設定させておきます。embedFormでembedしたFormオブジェクトは、このPostFormクラスのembedFormsフィールドに配列で保持されているので(sfForm.class.php参照)、これをループで回して、どのpostと関連つけているかを指定してあげます。ついでにファイルがアップロードされない場合も保存されてしまいますので、getFilename()が空文字列の場合は、embedFormsからその要素をunsetしておきます。これで、ファイルが入っていたときのみ、保存されることになります。
これで新規投稿に関してはファイルがアップロードがちゃんとできるようになりました。また、編集時には、ちゃんとそのフォームに画像が表示されます。
しかし、このままでは実は編集時に使うfilename_deleteを使うことができないのですね。このままですと、そのpictureオブジェクトを削除せずにpictureオブジェクトのfilenameのカラムを空にしてくれるだけです。まぁ、当たり前と言えば当たり前か。ということで、ファイルを削除できるようにしてみます。
チェックボックスには、filename_deleteという名前を与えているので、フォームから渡ってきた値にfilename_deleteがあった場合には、そのPictureのオブジェクトをインスタンス変数のremovePicturesという配列に格納しておくことにします。そして、doSaveでDBをいじるときに一緒にremovePicturesにあるPictureオブジェクトを削除することにします。ということで、最終的にできあがったPostFormは以下のようになります。
class PostForm extends BasePostForm
{
private $removePictures = array();
public function configure() {
unset($this['created_at'], $this['updated_at']);
$this->setWidgets(array(
'id' => new sfWidgetFormInputHidden(),
'title' => new sfWidgetFormInput(array(
'label' => 'タイトル',
)),
'description' => new sfWidgetFormTextarea(array(
'label' => '説明',
)),
));
$this->setValidators(array(
'id' => new sfValidatorPropelChoice(
array('model' => 'Post', 'column' => 'id', 'required' => false)
),
'title' => new sfValidatorString(
array('max_length' => 128, 'min_length' => 3),
array(
'max_length' => 'タイトルは128文字以内でお願いします。',
'min_length' => 'タイトルは3文字以上でお願いします。'
)
),
'description' => new sfValidatorString(
array('max_length' => 2048, 'min_length' => 3),
array(
'max_length' => '説明本文は2000文字以内でお願いします。',
'min_length' => '説明本文は3文字以上でお願いします。'
)
),
));
$pictures = ($this->getObject()->isNew()) ? null: $this->getObject()->getPictures();
for ($i = 0; $i < 3; $i++) {
$picture = (isset($pictures) and isset($pictures[$i])) ? $pictures[$i] : null;
$pictureForm = new PictureForm($picture);
$this->embedForm('picture_' . $i, $pictureForm, '%content%');
$this->widgetSchema['picture_' . $i]->setLabel('画像');
}
$this->widgetSchema->setNameFormat('post[%s]');
}
public function updateObject($values = null)
{
$object = parent::updateObject($values);
$values = $this->getValues();
foreach ($this->embeddedForms as $i => $picture) {
if (isset($values[$i]['filename_delete'])) {
$this->removePictures[] = $picture->getObject();
} else if ($picture->getObject()->getFilename() == '') {
unset($this->embeddedForms[$i]);
} else {
$picture->getObject()->setPost($object);
}
}
}
public function doSave($con = null)
{
parent::doSave($con);
foreach ($this->removePictures as $p) {
$p->delete($con);
}
}
}
symfony1.2を使いこなせるかどうかのポイントの一つはsfFormがちゃんと使えるかどうかになると思います。今後もsfFormを追いかけてみようと思います。
propel:generate-module post Postで生成されたindexSuccess.phpも少しだけ修正して、ちゃんと一覧画面に表示させるようにてみますか。
Post List
Id
Title
Description
Created at
Pictures
getId() ?>
getTitle() ?>
getDescription() ?>
getCreatedAt() ?>
getPictures() as $picture): ?>
New
本当は、編集フォームページにあるDeleteボタンを押すと、ちゃんとデータベースからはPostとそれに関連するPictureが削除されるのですが、実際の画像はunlinkされないので、そこの修正も必要になりますね。編集フォームページで画像だけを削除する際には、sfPropelFormにあるremoveFileが呼ばれますので、unlinkされるのですが、単純にdeleteアクションだけを呼んだ場合には、formクラスとは別のロジックになりますので、ファイルは消えませんので、Pictureモデルのdeleteにでも書いて置かないといけなさそうですね。ちょっとredundantなので、微妙だなぁーと思って今回は、それは載せませんでした。つーか、そもそも削除させないとかw
今回は、サンプルでしたので、書きませんでしたが、画像をconvertしたりする必要があるかもしれないですね。私が作っている本番用では、同時にサムネイルを作ったり、もっとformが複雑だったり、キャプションの与え方が動的だったりします。しかし、embedFormのやり方はこのままですので、これを元にすれば、いろんなところに適応ができそうです。
今回は、symfony1.2を用いて、複数の画像投稿を実現するために、embedFormを用いて実装する方法を説明しました。確かにその複数分ループで回せばいいのですが、embedFormForEachがちゃんと動けば、そちらの方がスマートな気がします。それができるまでは、このembedFormで実現しましょう。
間違いがありましたら、教えてくださいな。